diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 2821501..b8e41ec 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -80,9 +80,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { })() : userInfo.picture; - const hasUsers = await context.auth.hasAnyUsers(); - const defaultRole = hasUsers ? "member" : "owner"; - const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole); + const userId = await context.auth.findOrCreateUser(claims.sub); try { const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey); diff --git a/app/routes/users/onboarding-skip.tsx b/app/routes/users/onboarding-skip.tsx index 42c261b..ccf72db 100644 --- a/app/routes/users/onboarding-skip.tsx +++ b/app/routes/users/onboarding-skip.tsx @@ -34,7 +34,10 @@ export async function action({ request, context }: Route.ActionArgs) { const headscaleUserId = formData.get("headscale_user_id")?.toString(); if (headscaleUserId) { - await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); + const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); + if (!linked) { + return redirect("/onboarding"); + } } await context.db diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index dd84af2..b0ff8de 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -74,10 +74,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { firstMachine = nodes.find((n) => n.user?.id === matched.id); } else { needsUserLink = true; - headscaleUsers = apiUsers.map((u) => ({ - id: u.id, - name: getUserDisplayName(u), - })); + const claimed = await context.auth.claimedHeadscaleUserIds(); + headscaleUsers = apiUsers + .filter((u) => !claimed.has(u.id)) + .map((u) => ({ + id: u.id, + name: getUserDisplayName(u), + })); } } } catch (e) { diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index b3bc880..e9977d9 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -23,7 +23,7 @@ export const users = sqliteTable("users", { id: text("id").primaryKey(), sub: text("sub").notNull().unique(), role: text("role").notNull().default("member"), - headscale_user_id: text("headscale_user_id"), + headscale_user_id: text("headscale_user_id").unique(), onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false), created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()), updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()), diff --git a/app/server/index.ts b/app/server/index.ts index b2ed5b3..f66cbc5 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -129,6 +129,14 @@ export default createHonoServer({ }, }); +// Prune expired auth sessions every 15 minutes +setInterval( + () => { + appLoadContext.auth.pruneExpiredSessions(); + }, + 15 * 60 * 1000, +); + process.on("SIGINT", () => { log.info("server", "Received SIGINT, shutting down..."); process.exit(0); diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index e3f1f80..cc88c9e 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -317,13 +317,42 @@ export class AuthService { } /** - * Update the Headscale user link for a Headplane user. + * Link a Headplane user to a Headscale user. Returns false if the + * Headscale user is already claimed by another Headplane user. */ - async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { + async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { + const [existing] = await this.opts.db + .select({ id: users.id }) + .from(users) + .where(eq(users.headscale_user_id, headscaleUserId)) + .limit(1); + + if (existing && existing.id !== userId) { + return false; + } + await this.opts.db .update(users) .set({ headscale_user_id: headscaleUserId, updated_at: new Date() }) .where(eq(users.id, userId)); + + return true; + } + + /** + * Returns the set of Headscale user IDs that are already claimed + * by a Headplane user. Used to filter the onboarding dropdown. + */ + async claimedHeadscaleUserIds(): Promise> { + const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users); + + const ids = new Set(); + for (const row of rows) { + if (row.hsId) { + ids.add(row.hsId); + } + } + return ids; } /** diff --git a/drizzle/0003_thick_otto_octavius.sql b/drizzle/0003_thick_otto_octavius.sql index f6118c0..ff95d74 100644 --- a/drizzle/0003_thick_otto_octavius.sql +++ b/drizzle/0003_thick_otto_octavius.sql @@ -10,6 +10,7 @@ CREATE TABLE `auth_sessions` ( --> statement-breakpoint ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint +CREATE UNIQUE INDEX `users_headscale_user_id_unique` ON `users` (`headscale_user_id`);--> statement-breakpoint ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json index c0c1835..fadb54e 100644 --- a/drizzle/meta/0003_snapshot.json +++ b/drizzle/meta/0003_snapshot.json @@ -193,6 +193,11 @@ "name": "users_sub_unique", "columns": ["sub"], "isUnique": true + }, + "users_headscale_user_id_unique": { + "name": "users_headscale_user_id_unique", + "columns": ["headscale_user_id"], + "isUnique": true } }, "foreignKeys": {},