Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions dembrane/platform/packages/auth/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import type postgres from "postgres";
/**
* Copies Directus identities into Better Auth's tables. Idempotent: runs with every
* migration job until cutover, so users created through Directus in the meantime can sign
* in to the new stack with the same password or Google account. Suspended users are left
* out, and a user already present is never overwritten.
* in to the new stack with the same password or Google account. Every user with an email is
* copied whatever its status, so none is left without an identity: a suspended or archived
* one still gets no session (auth.ts refuses it by its Directus status) and can be made
* active again later, and an unverified or invited one has to prove its email first. A user
* already present is never overwritten.
*/
export async function syncIdentitiesFromDirectus(
sql: postgres.Sql,
Expand All @@ -13,9 +16,9 @@ export async function syncIdentitiesFromDirectus(
insert into auth_user (id, name, email, email_verified, created_at, updated_at)
select d.id,
coalesce(nullif(trim(concat_ws(' ', d.first_name, d.last_name)), ''), split_part(d.email, '@', 1)),
lower(d.email), true, now(), now()
lower(d.email), d.status in ('active', 'suspended', 'archived'), now(), now()
from directus_users d
where d.email is not null and d.status = 'active'
where d.email is not null
on conflict do nothing
returning id`;
const passwords = await sql`
Expand Down
28 changes: 28 additions & 0 deletions dembrane/platform/packages/auth/test/auth.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,34 @@ run("auth on Directus-created users", () => {
await sql.end();
});

test("a user of any status gets an identity: suspended stays out, unverified proves its email first", async () => {
const sql = connect(url as string, { max: 1, onnotice: () => {} });
const stamp = Date.now();
const email = (status: string) => `${status}-${stamp}@example.com`;
const statuses = ["suspended", "archived", "unverified", "invited"];
// Each takes alice's hash, so the password is one Directus made.
for (const status of statuses)
await sql`
insert into directus_users (id, email, status, password, provider)
select gen_random_uuid(), ${email(status)}, ${status}, password, 'default'
from directus_users where email = 'alice.parity@example.com'`;
expect((await syncIdentitiesFromDirectus(sql)).users).toBe(statuses.length);
const rows = await sql<{ email: string; email_verified: boolean }[]>`
select email, email_verified from auth_user where email like ${`%-${stamp}@example.com`}`;
await sql.end();
const verified = Object.fromEntries(rows.map((r) => [r.email, r.email_verified]));
expect(verified).toEqual({
[email("suspended")]: true,
[email("archived")]: true,
[email("unverified")]: false,
[email("invited")]: false,
});
for (const status of ["suspended", "archived"])
await expect(
auth.api.signInEmail({ body: { email: email(status), password: password as string } }),
).rejects.toThrow("This account is not active");
});

test("a password Directus hashed signs in, and the session belongs to the same user id", async () => {
const res = await auth.api.signInEmail({
body: { email: "alice.parity@example.com", password: password as string },
Expand Down
Loading