diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx
index 349866a..af43b41 100644
--- a/app/routes/auth/login/page.tsx
+++ b/app/routes/auth/login/page.tsx
@@ -1,6 +1,6 @@
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
-import { Form, redirect, useSearchParams } from "react-router";
+import { Form, Link as RouterLink, redirect, useSearchParams } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
@@ -139,11 +139,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
{isOidcConnectorEnabled ? (
-
+
-
+
) : undefined}
diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts
index 0d3eaa1..761f62e 100644
--- a/app/routes/auth/oidc-callback.ts
+++ b/app/routes/auth/oidc-callback.ts
@@ -103,6 +103,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const userId = await context.auth.findOrCreateUser(claims.sub, {
name,
email: userInfo.email,
+ picture,
});
try {
@@ -122,7 +123,6 @@ export async function loader({ request, context }: Route.LoaderArgs) {
name,
email: userInfo.email,
username,
- picture,
}),
},
});
diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts
index 773b95c..ce8d5fc 100644
--- a/app/server/db/schema.ts
+++ b/app/server/db/schema.ts
@@ -24,6 +24,7 @@ export const users = sqliteTable("users", {
sub: text("sub").notNull().unique(),
name: text("name"),
email: text("email"),
+ picture: text("picture"),
role: text("role").notNull().default("member"),
headscale_user_id: text("headscale_user_id").unique(),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts
index 9d790ad..539c252 100644
--- a/app/server/web/auth.ts
+++ b/app/server/web/auth.ts
@@ -51,7 +51,6 @@ interface CookiePayload {
name: string;
email?: string;
username?: string;
- picture?: string;
};
}
@@ -149,9 +148,11 @@ export class AuthService {
role,
headscaleUserId: user.headscale_user_id ?? undefined,
},
- profile: payload.profile ?? {
- name: user.name ?? user.sub,
- email: user.email ?? undefined,
+ profile: {
+ name: payload.profile?.name ?? user.name ?? user.sub,
+ email: payload.profile?.email ?? user.email ?? undefined,
+ username: payload.profile?.username,
+ picture: user.picture ?? undefined,
},
};
}
@@ -278,7 +279,7 @@ export class AuthService {
*/
async findOrCreateUser(
subject: string,
- profile?: { name?: string; email?: string },
+ profile?: { name?: string; email?: string; picture?: string },
): Promise {
const [existing] = await this.opts.db
.select()
@@ -292,6 +293,7 @@ export class AuthService {
.set({
name: profile?.name,
email: profile?.email,
+ picture: profile?.picture,
last_login_at: new Date(),
updated_at: new Date(),
})
@@ -305,6 +307,7 @@ export class AuthService {
sub: subject,
name: profile?.name,
email: profile?.email,
+ picture: profile?.picture,
role: "member",
caps: capsForRole("member"),
});
diff --git a/drizzle/20260330175441_add_user_picture/migration.sql b/drizzle/20260330175441_add_user_picture/migration.sql
new file mode 100644
index 0000000..a5fd32d
--- /dev/null
+++ b/drizzle/20260330175441_add_user_picture/migration.sql
@@ -0,0 +1,22 @@
+ALTER TABLE `users` ADD `picture` text;--> statement-breakpoint
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_users` (
+ `id` text PRIMARY KEY,
+ `sub` text NOT NULL UNIQUE,
+ `name` text,
+ `email` text,
+ `picture` text,
+ `role` text DEFAULT 'member' NOT NULL,
+ `headscale_user_id` text UNIQUE,
+ `created_at` integer,
+ `updated_at` integer,
+ `last_login_at` integer,
+ `caps` integer DEFAULT 0 NOT NULL
+);
+--> statement-breakpoint
+INSERT INTO `__new_users`(`id`, `sub`, `name`, `email`, `role`, `headscale_user_id`, `created_at`, `updated_at`, `last_login_at`, `caps`) SELECT `id`, `sub`, `name`, `email`, `role`, `headscale_user_id`, `created_at`, `updated_at`, `last_login_at`, `caps` FROM `users`;--> statement-breakpoint
+DROP TABLE `users`;--> statement-breakpoint
+ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+DROP INDEX IF EXISTS `users_sub_unique`;--> statement-breakpoint
+DROP INDEX IF EXISTS `users_headscale_user_id_unique`;
\ No newline at end of file
diff --git a/drizzle/20260330175441_add_user_picture/snapshot.json b/drizzle/20260330175441_add_user_picture/snapshot.json
new file mode 100644
index 0000000..9bb26ff
--- /dev/null
+++ b/drizzle/20260330175441_add_user_picture/snapshot.json
@@ -0,0 +1,297 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "b0533d83-6eb6-422f-925f-c59e4db89384",
+ "prevIds": ["6d6838a7-e7ab-4661-bc7d-b47566bfff13"],
+ "ddl": [
+ {
+ "name": "auth_sessions",
+ "entityType": "tables"
+ },
+ {
+ "name": "ephemeral_nodes",
+ "entityType": "tables"
+ },
+ {
+ "name": "host_info",
+ "entityType": "tables"
+ },
+ {
+ "name": "users",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "auth_sessions"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "kind",
+ "entityType": "columns",
+ "table": "auth_sessions"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "auth_sessions"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "api_key_hash",
+ "entityType": "columns",
+ "table": "auth_sessions"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "api_key_display",
+ "entityType": "columns",
+ "table": "auth_sessions"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "table": "auth_sessions"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "table": "auth_sessions"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "auth_key",
+ "entityType": "columns",
+ "table": "ephemeral_nodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "node_key",
+ "entityType": "columns",
+ "table": "ephemeral_nodes"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "host_id",
+ "entityType": "columns",
+ "table": "host_info"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "payload",
+ "entityType": "columns",
+ "table": "host_info"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "table": "host_info"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sub",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "picture",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'",
+ "generated": null,
+ "name": "role",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "headscale_user_id",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "last_login_at",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "0",
+ "generated": null,
+ "name": "caps",
+ "entityType": "columns",
+ "table": "users"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "auth_sessions_pk",
+ "table": "auth_sessions",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["auth_key"],
+ "nameExplicit": false,
+ "name": "ephemeral_nodes_pk",
+ "table": "ephemeral_nodes",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["host_id"],
+ "nameExplicit": false,
+ "name": "host_info_pk",
+ "table": "host_info",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "users_pk",
+ "table": "users",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["sub"],
+ "nameExplicit": false,
+ "name": "users_sub_unique",
+ "entityType": "uniques",
+ "table": "users"
+ },
+ {
+ "columns": ["headscale_user_id"],
+ "nameExplicit": false,
+ "name": "users_headscale_user_id_unique",
+ "entityType": "uniques",
+ "table": "users"
+ }
+ ],
+ "renames": []
+}