From 22b2fb55eac27d521cdc1016c581a4cdbdf5792f Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:18:48 +0800 Subject: [PATCH] feat: add role for each user --- .github/workflows/create-release.yaml | 18 + .releaserc | 3 + actions/admin/clients/roles.ts | 189 +++ actions/admin/users/roles.ts | 143 ++ app/admin/dashboard/clients/[id]/page.tsx | 7 + app/admin/dashboard/users/page.tsx | 41 +- .../account-creation/verify/route.test.ts | 7 + .../passkey/authenticate/verify/route.test.ts | 7 + .../passkey/register/verify/route.test.ts | 7 + app/api/oauth/signup/route.test.ts | 8 + app/api/oauth/token/password-grant.test.ts | 8 + app/api/oauth/token/route.ts | 6 + app/api/oauth/userinfo/route.ts | 3 + components/admin/client-detail-tabs.tsx | 6 +- components/admin/client-roles-card.tsx | 260 ++++ components/admin/user-list.tsx | 4 + components/admin/user-role-assignments.tsx | 259 ++++ components/admin/user-sheet.tsx | 67 +- lib/db/migrations/0007_app_scoped_roles.sql | 27 + lib/db/migrations/meta/0007_snapshot.json | 1218 +++++++++++++++++ lib/db/migrations/meta/_journal.json | 9 +- lib/db/schema/index.ts | 1 + lib/db/schema/oauth-client-roles.ts | 66 + lib/oauth/issue-tokens.ts | 4 + lib/oauth/jwt.ts | 2 + lib/oauth/roles.ts | 26 + lib/validations/admin.test.ts | 38 + lib/validations/admin.ts | 46 + 28 files changed, 2469 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/create-release.yaml create mode 100644 .releaserc create mode 100644 actions/admin/clients/roles.ts create mode 100644 actions/admin/users/roles.ts create mode 100644 components/admin/client-roles-card.tsx create mode 100644 components/admin/user-role-assignments.tsx create mode 100644 lib/db/migrations/0007_app_scoped_roles.sql create mode 100644 lib/db/migrations/meta/0007_snapshot.json create mode 100644 lib/db/schema/oauth-client-roles.ts create mode 100644 lib/oauth/roles.ts diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml new file mode 100644 index 0000000..fcbfa7d --- /dev/null +++ b/.github/workflows/create-release.yaml @@ -0,0 +1,18 @@ +on: workflow_dispatch +name: Create a new release + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + if: ${{ (github.event.pusher.name != 'github action') && (github.ref == 'refs/heads/main') }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Semantic Release + uses: cycjimmy/semantic-release-action@v6 + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + with: + branch: main diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..5bc96d2 --- /dev/null +++ b/.releaserc @@ -0,0 +1,3 @@ +{ + plugins: ['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', '@semantic-release/github'] +} diff --git a/actions/admin/clients/roles.ts b/actions/admin/clients/roles.ts new file mode 100644 index 0000000..c039ada --- /dev/null +++ b/actions/admin/clients/roles.ts @@ -0,0 +1,189 @@ +"use server"; + +import { db } from "@/lib/db"; +import { oauthClientRoles } from "@/lib/db/schema"; +import { requireAdmin } from "@/lib/auth/session"; +import { + createClientRoleSchema, + updateClientRoleSchema, + type CreateClientRoleInput, + type UpdateClientRoleInput, +} from "@/lib/validations/admin"; +import { and, eq, not } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import type { OAuthClientRole } from "@/lib/db/schema"; + +export interface ClientRoleResult { + success: boolean; + error?: string; + role?: OAuthClientRole; +} + +export interface GetClientRolesResult { + success: boolean; + error?: string; + data?: OAuthClientRole[]; +} + +export async function getClientRoles( + clientId: string +): Promise { + try { + await requireAdmin(); + + const roles = await db.query.oauthClientRoles.findMany({ + where: eq(oauthClientRoles.clientId, clientId), + orderBy: (table, { asc }) => [asc(table.name)], + }); + + return { success: true, data: roles }; + } catch (error) { + console.error("Get client roles error:", error); + return { success: false, error: "Failed to get roles" }; + } +} + +export async function createClientRole( + input: CreateClientRoleInput +): Promise { + try { + await requireAdmin(); + + const parsed = createClientRoleSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message || "Invalid input", + }; + } + + const { clientId, key, name } = parsed.data; + const normalizedKey = key.toLowerCase(); + + const existing = await db.query.oauthClientRoles.findFirst({ + where: and( + eq(oauthClientRoles.clientId, clientId), + eq(oauthClientRoles.key, normalizedKey) + ), + }); + + if (existing) { + return { + success: false, + error: "Role key is already used for this app", + }; + } + + const now = new Date(); + const role: OAuthClientRole = { + id: crypto.randomUUID(), + clientId, + key: normalizedKey, + name, + createdAt: now, + updatedAt: now, + }; + + await db.insert(oauthClientRoles).values(role); + + revalidatePath(`/admin/dashboard/clients/${clientId}`); + revalidatePath("/admin/dashboard/users"); + + return { success: true, role }; + } catch (error) { + console.error("Create client role error:", error); + return { success: false, error: "Failed to create role" }; + } +} + +export async function updateClientRole( + input: UpdateClientRoleInput +): Promise { + try { + await requireAdmin(); + + const parsed = updateClientRoleSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message || "Invalid input", + }; + } + + const { roleId, clientId, key, name } = parsed.data; + const normalizedKey = key.toLowerCase(); + + const existingRole = await db.query.oauthClientRoles.findFirst({ + where: and( + eq(oauthClientRoles.id, roleId), + eq(oauthClientRoles.clientId, clientId) + ), + }); + + if (!existingRole) { + return { success: false, error: "Role not found" }; + } + + const duplicate = await db.query.oauthClientRoles.findFirst({ + where: and( + eq(oauthClientRoles.clientId, clientId), + eq(oauthClientRoles.key, normalizedKey), + not(eq(oauthClientRoles.id, roleId)) + ), + }); + + if (duplicate) { + return { + success: false, + error: "Role key is already used for this app", + }; + } + + await db + .update(oauthClientRoles) + .set({ + key: normalizedKey, + name, + updatedAt: new Date(), + }) + .where(eq(oauthClientRoles.id, roleId)); + + const role = await db.query.oauthClientRoles.findFirst({ + where: eq(oauthClientRoles.id, roleId), + }); + + revalidatePath(`/admin/dashboard/clients/${clientId}`); + revalidatePath("/admin/dashboard/users"); + + return { success: true, role }; + } catch (error) { + console.error("Update client role error:", error); + return { success: false, error: "Failed to update role" }; + } +} + +export async function deleteClientRole( + roleId: string, + clientId: string +): Promise { + try { + await requireAdmin(); + + await db + .delete(oauthClientRoles) + .where( + and( + eq(oauthClientRoles.id, roleId), + eq(oauthClientRoles.clientId, clientId) + ) + ); + + revalidatePath(`/admin/dashboard/clients/${clientId}`); + revalidatePath("/admin/dashboard/users"); + + return { success: true }; + } catch (error) { + console.error("Delete client role error:", error); + return { success: false, error: "Failed to delete role" }; + } +} diff --git a/actions/admin/users/roles.ts b/actions/admin/users/roles.ts new file mode 100644 index 0000000..d0272a9 --- /dev/null +++ b/actions/admin/users/roles.ts @@ -0,0 +1,143 @@ +"use server"; + +import { db } from "@/lib/db"; +import { + oauthClientRoles, + oauthClientUserRoles, + users, +} from "@/lib/db/schema"; +import { requireAdmin } from "@/lib/auth/session"; +import { + setUserRolesSchema, + type SetUserRolesInput, +} from "@/lib/validations/admin"; +import { eq, inArray } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export interface UserRoleAssignment { + clientId: string; + roleIds: string[]; +} + +export interface GetUserRolesResult { + success: boolean; + error?: string; + data?: UserRoleAssignment[]; +} + +export interface SetUserRolesResult { + success: boolean; + error?: string; + data?: UserRoleAssignment[]; +} + +export async function getUserRoleAssignments( + userId: string +): Promise { + try { + await requireAdmin(); + + const rows = await db.query.oauthClientUserRoles.findMany({ + where: eq(oauthClientUserRoles.userId, userId), + orderBy: (table, { asc }) => [asc(table.clientId)], + }); + + const byClient = new Map(); + for (const row of rows) { + const roleIds = byClient.get(row.clientId) ?? []; + roleIds.push(row.roleId); + byClient.set(row.clientId, roleIds); + } + + return { + success: true, + data: Array.from(byClient.entries()).map(([clientId, roleIds]) => ({ + clientId, + roleIds, + })), + }; + } catch (error) { + console.error("Get user role assignments error:", error); + return { success: false, error: "Failed to get user roles" }; + } +} + +export async function setUserRoleAssignments( + input: SetUserRolesInput +): Promise { + try { + await requireAdmin(); + + const parsed = setUserRolesSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message || "Invalid input", + }; + } + + const { userId, assignments } = parsed.data; + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + if (!user) { + return { success: false, error: "User not found" }; + } + + const normalizedAssignments = assignments.map((assignment) => ({ + clientId: assignment.clientId, + roleIds: Array.from(new Set(assignment.roleIds)), + })); + const requestedRoleIds = normalizedAssignments.flatMap( + (assignment) => assignment.roleIds + ); + + if (requestedRoleIds.length > 0) { + const roles = await db.query.oauthClientRoles.findMany({ + where: inArray(oauthClientRoles.id, requestedRoleIds), + }); + const rolesById = new Map(roles.map((role) => [role.id, role])); + + for (const assignment of normalizedAssignments) { + for (const roleId of assignment.roleIds) { + const role = rolesById.get(roleId); + if (!role || role.clientId !== assignment.clientId) { + return { + success: false, + error: "Role does not belong to the selected app", + }; + } + } + } + } + + await db.transaction(async (tx) => { + await tx + .delete(oauthClientUserRoles) + .where(eq(oauthClientUserRoles.userId, userId)); + + const rows = normalizedAssignments.flatMap((assignment) => + assignment.roleIds.map((roleId) => ({ + id: crypto.randomUUID(), + clientId: assignment.clientId, + userId, + roleId, + createdAt: new Date(), + })) + ); + + if (rows.length > 0) { + await tx.insert(oauthClientUserRoles).values(rows); + } + }); + + revalidatePath("/admin/dashboard/users"); + + return { success: true, data: normalizedAssignments }; + } catch (error) { + console.error("Set user role assignments error:", error); + return { success: false, error: "Failed to update user roles" }; + } +} diff --git a/app/admin/dashboard/clients/[id]/page.tsx b/app/admin/dashboard/clients/[id]/page.tsx index fd5320a..0f69af7 100644 --- a/app/admin/dashboard/clients/[id]/page.tsx +++ b/app/admin/dashboard/clients/[id]/page.tsx @@ -4,6 +4,7 @@ import { oauthClients, oauthClientEmailWhitelist, oauthClientAppIds, + oauthClientRoles, } from "@/lib/db/schema"; import { ClientDetailTabs } from "@/components/admin/client-detail-tabs"; import { getOpenIDConfiguration } from "@/lib/oauth/discovery"; @@ -50,6 +51,11 @@ export default async function EditClientPage({ params }: PageProps) { orderBy: (table, { desc }) => [desc(table.createdAt)], }); + const roles = await db.query.oauthClientRoles.findMany({ + where: eq(oauthClientRoles.clientId, id), + orderBy: (table, { asc }) => [asc(table.name)], + }); + const config = getOpenIDConfiguration(); const endpoints = { issuer: config.issuer, @@ -63,6 +69,7 @@ export default async function EditClientPage({ params }: PageProps) { client={client} whitelistEmails={whitelistEmails} appIds={appIds} + roles={roles} endpoints={endpoints} /> ); diff --git a/app/admin/dashboard/users/page.tsx b/app/admin/dashboard/users/page.tsx index 9430f83..d60d7dd 100644 --- a/app/admin/dashboard/users/page.tsx +++ b/app/admin/dashboard/users/page.tsx @@ -1,8 +1,9 @@ import { db } from "@/lib/db"; -import { users } from "@/lib/db/schema"; -import { desc, sql } from "drizzle-orm"; +import { oauthClients, oauthClientRoles, users } from "@/lib/db/schema"; +import { asc, desc, sql } from "drizzle-orm"; import { PageHeader } from "@/components/dashboard"; import { UserList } from "@/components/admin/user-list"; +import type { UserRoleOptionApp } from "@/components/admin/user-role-assignments"; export const metadata = { title: "Users - Admin", @@ -36,6 +37,41 @@ export default async function UsersPage() { const nextCursor = hasMore && lastUser ? encodeCursor(lastUser.createdAt, lastUser.id) : null; + const clients = await db + .select({ + id: oauthClients.id, + name: oauthClients.name, + }) + .from(oauthClients) + .orderBy(asc(oauthClients.name)); + + const roles = await db + .select({ + id: oauthClientRoles.id, + clientId: oauthClientRoles.clientId, + key: oauthClientRoles.key, + name: oauthClientRoles.name, + }) + .from(oauthClientRoles) + .orderBy(asc(oauthClientRoles.name)); + + const rolesByClient = new Map(); + for (const role of roles) { + const clientRoles = rolesByClient.get(role.clientId) ?? []; + clientRoles.push({ + id: role.id, + key: role.key, + name: role.name, + }); + rolesByClient.set(role.clientId, clientRoles); + } + + const roleOptions: UserRoleOptionApp[] = clients.map((client) => ({ + id: client.id, + name: client.name, + roles: rolesByClient.get(client.id) ?? [], + })); + return (
); diff --git a/app/api/oauth/passkey/account-creation/verify/route.test.ts b/app/api/oauth/passkey/account-creation/verify/route.test.ts index dbc6e44..acf3238 100644 --- a/app/api/oauth/passkey/account-creation/verify/route.test.ts +++ b/app/api/oauth/passkey/account-creation/verify/route.test.ts @@ -37,6 +37,7 @@ const verifyRegistrationResponseMock = mock(); const signAccessTokenMock = mock(); const signIdTokenMock = mock(); const generateRefreshTokenMock = mock(); +const getUserRoleKeysMock = mock(); const generateAvatarSeedMock = mock(); const insertValues = mock(); const transactionMock = mock(); @@ -75,6 +76,10 @@ mock.module("@/lib/oauth/jwt", () => ({ verifyAccessToken: mock(), })); +mock.module("@/lib/oauth/roles", () => ({ + getUserRoleKeys: getUserRoleKeysMock, +})); + mock.module("@/lib/identicon/generate", () => ({ generateAvatarSeed: generateAvatarSeedMock, generateIdenticon: mock(), @@ -140,6 +145,7 @@ describe("POST /api/oauth/passkey/account-creation/verify", () => { signAccessTokenMock.mockReset(); signIdTokenMock.mockReset(); generateRefreshTokenMock.mockReset(); + getUserRoleKeysMock.mockReset(); generateAvatarSeedMock.mockReset(); insertValues.mockReset(); transactionMock.mockReset(); @@ -164,6 +170,7 @@ describe("POST /api/oauth/passkey/account-creation/verify", () => { signAccessTokenMock.mockResolvedValue("access-token-stub"); signIdTokenMock.mockResolvedValue("id-token-stub"); generateRefreshTokenMock.mockReturnValue("refresh-token-stub"); + getUserRoleKeysMock.mockResolvedValue(["member"]); generateAvatarSeedMock.mockReturnValue("seed-stub"); insertValues.mockResolvedValue(undefined); transactionMock.mockImplementation(async (cb: (tx: unknown) => unknown) => { diff --git a/app/api/oauth/passkey/authenticate/verify/route.test.ts b/app/api/oauth/passkey/authenticate/verify/route.test.ts index c2c40ce..2e09824 100644 --- a/app/api/oauth/passkey/authenticate/verify/route.test.ts +++ b/app/api/oauth/passkey/authenticate/verify/route.test.ts @@ -61,6 +61,7 @@ const verifyAuthenticationResponseMock = mock(); const signAccessTokenMock = mock(); const signIdTokenMock = mock(); const generateRefreshTokenMock = mock(); +const getUserRoleKeysMock = mock(); const insertValues = mock(); const updateSet = mock(); @@ -98,6 +99,10 @@ mock.module("@/lib/oauth/jwt", () => ({ generateRefreshToken: generateRefreshTokenMock, })); +mock.module("@/lib/oauth/roles", () => ({ + getUserRoleKeys: getUserRoleKeysMock, +})); + const { POST } = await import("./route"); function makeRequest(body: Record): Request { @@ -139,6 +144,7 @@ describe("POST /api/oauth/passkey/authenticate/verify", () => { signAccessTokenMock.mockReset(); signIdTokenMock.mockReset(); generateRefreshTokenMock.mockReset(); + getUserRoleKeysMock.mockReset(); insertValues.mockReset(); updateSet.mockReset(); @@ -154,6 +160,7 @@ describe("POST /api/oauth/passkey/authenticate/verify", () => { signAccessTokenMock.mockResolvedValue("access-token-stub"); signIdTokenMock.mockResolvedValue("id-token-stub"); generateRefreshTokenMock.mockReturnValue("refresh-token-stub"); + getUserRoleKeysMock.mockResolvedValue(["member"]); insertValues.mockResolvedValue(undefined); updateSet.mockReturnValue({ where: () => Promise.resolve() }); }); diff --git a/app/api/oauth/passkey/register/verify/route.test.ts b/app/api/oauth/passkey/register/verify/route.test.ts index cfdad6e..9a142c7 100644 --- a/app/api/oauth/passkey/register/verify/route.test.ts +++ b/app/api/oauth/passkey/register/verify/route.test.ts @@ -40,6 +40,7 @@ const verifyRegistrationResponseMock = mock(); const signAccessTokenMock = mock(); const signIdTokenMock = mock(); const generateRefreshTokenMock = mock(); +const getUserRoleKeysMock = mock(); const generateAvatarSeedMock = mock(); const insertValues = mock(); const transactionMock = mock(); @@ -77,6 +78,10 @@ mock.module("@/lib/oauth/jwt", () => ({ generateRefreshToken: generateRefreshTokenMock, })); +mock.module("@/lib/oauth/roles", () => ({ + getUserRoleKeys: getUserRoleKeysMock, +})); + mock.module("@/lib/identicon/generate", () => ({ generateAvatarSeed: generateAvatarSeedMock, generateIdenticon: mock(), @@ -122,6 +127,7 @@ describe("POST /api/oauth/passkey/register/verify", () => { signAccessTokenMock.mockReset(); signIdTokenMock.mockReset(); generateRefreshTokenMock.mockReset(); + getUserRoleKeysMock.mockReset(); generateAvatarSeedMock.mockReset(); insertValues.mockReset(); transactionMock.mockReset(); @@ -145,6 +151,7 @@ describe("POST /api/oauth/passkey/register/verify", () => { signAccessTokenMock.mockResolvedValue("access-token-stub"); signIdTokenMock.mockResolvedValue("id-token-stub"); generateRefreshTokenMock.mockReturnValue("refresh-token-stub"); + getUserRoleKeysMock.mockResolvedValue(["member"]); generateAvatarSeedMock.mockReturnValue("seed-stub"); insertValues.mockResolvedValue(undefined); transactionMock.mockImplementation(async (cb: (tx: unknown) => unknown) => { diff --git a/app/api/oauth/signup/route.test.ts b/app/api/oauth/signup/route.test.ts index 41cf876..a1b9f27 100644 --- a/app/api/oauth/signup/route.test.ts +++ b/app/api/oauth/signup/route.test.ts @@ -23,6 +23,7 @@ const hashPasswordMock = mock(); const signAccessTokenMock = mock(); const signIdTokenMock = mock(); const generateRefreshTokenMock = mock(); +const getUserRoleKeysMock = mock(); const generateAvatarSeedMock = mock(); const sendEmailMock = mock(); const insertValues = mock(); @@ -50,6 +51,10 @@ mock.module("@/lib/oauth/jwt", () => ({ generateRefreshToken: generateRefreshTokenMock, })); +mock.module("@/lib/oauth/roles", () => ({ + getUserRoleKeys: getUserRoleKeysMock, +})); + mock.module("@/lib/settings/sign-up", () => ({ checkSignUpAllowed: checkSignUpAllowedMock, })); @@ -86,6 +91,7 @@ describe("POST /api/oauth/signup", () => { signAccessTokenMock.mockReset(); signIdTokenMock.mockReset(); generateRefreshTokenMock.mockReset(); + getUserRoleKeysMock.mockReset(); generateAvatarSeedMock.mockReset(); sendEmailMock.mockReset(); insertValues.mockReset(); @@ -98,6 +104,7 @@ describe("POST /api/oauth/signup", () => { signAccessTokenMock.mockResolvedValue("access-token-stub"); signIdTokenMock.mockResolvedValue("id-token-stub"); generateRefreshTokenMock.mockReturnValue("refresh-token-stub"); + getUserRoleKeysMock.mockResolvedValue(["member"]); generateAvatarSeedMock.mockReturnValue("seed-stub"); sendEmailMock.mockResolvedValue(undefined); insertValues.mockResolvedValue(undefined); @@ -129,6 +136,7 @@ describe("POST /api/oauth/signup", () => { expect(body.token_type).toBe("Bearer"); expect(body.scope).toBe("openid email"); expect(hashPasswordMock).toHaveBeenCalledWith("correct-horse"); + expect(signAccessTokenMock.mock.calls[0][0].roles).toEqual(["member"]); // 2 inserts: users + oauthRefreshTokens expect(insertValues).toHaveBeenCalledTimes(2); }); diff --git a/app/api/oauth/token/password-grant.test.ts b/app/api/oauth/token/password-grant.test.ts index 53ad45c..fec7f19 100644 --- a/app/api/oauth/token/password-grant.test.ts +++ b/app/api/oauth/token/password-grant.test.ts @@ -39,6 +39,7 @@ const verifyPasswordMock = mock(); const signAccessTokenMock = mock(); const signIdTokenMock = mock(); const generateRefreshTokenMock = mock(); +const getUserRoleKeysMock = mock(); const insertValues = mock(); mock.module("@/lib/db", () => ({ @@ -65,6 +66,10 @@ mock.module("@/lib/oauth/jwt", () => ({ verifyAccessToken: mock(), })); +mock.module("@/lib/oauth/roles", () => ({ + getUserRoleKeys: getUserRoleKeysMock, +})); + mock.module("@/lib/redis", () => ({ getOAuthCode: mock(), deleteOAuthCode: mock(), @@ -95,6 +100,7 @@ describe("POST /api/oauth/token - grant_type=password", () => { signAccessTokenMock.mockReset(); signIdTokenMock.mockReset(); generateRefreshTokenMock.mockReset(); + getUserRoleKeysMock.mockReset(); insertValues.mockReset(); findClient.mockResolvedValue(VALID_CLIENT); @@ -105,6 +111,7 @@ describe("POST /api/oauth/token - grant_type=password", () => { signAccessTokenMock.mockResolvedValue("access-token-stub"); signIdTokenMock.mockResolvedValue("id-token-stub"); generateRefreshTokenMock.mockReturnValue("refresh-token-stub"); + getUserRoleKeysMock.mockResolvedValue(["admin"]); insertValues.mockResolvedValue(undefined); }); @@ -131,6 +138,7 @@ describe("POST /api/oauth/token - grant_type=password", () => { "correct-horse", ); expect(insertValues).toHaveBeenCalled(); + expect(signAccessTokenMock.mock.calls[0][0].roles).toEqual(["admin"]); }); test("happy path: omits id_token when openid scope not requested", async () => { diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts index 928d7a8..e82cd5e 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -15,6 +15,7 @@ import { generateRefreshToken, } from "@/lib/oauth/jwt"; import { issueOAuthTokenResponse } from "@/lib/oauth/issue-tokens"; +import { getUserRoleKeys } from "@/lib/oauth/roles"; import { parseBasicAuth } from "@/lib/oauth/basic-auth"; import { tokenRequestSchema } from "@/lib/validations/oauth"; import { eq, and, isNull, or, gt } from "drizzle-orm"; @@ -247,11 +248,13 @@ async function handleAuthorizationCodeGrant( // Generate tokens const scopeString = codeData.scopes.join(" "); + const roles = await getUserRoleKeys(user.id, client.id); const accessToken = await signAccessToken({ sub: user.id, client_id: client.id, scope: scopeString, + roles, }); const idToken = await signIdToken( @@ -266,6 +269,7 @@ async function handleAuthorizationCodeGrant( name: user.displayName ?? undefined, preferred_username: user.username ?? undefined, picture: user.avatarUrl || `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, + roles, nonce: codeData.nonce, auth_time: Math.floor(Date.now() / 1000), }, @@ -371,12 +375,14 @@ async function handleRefreshTokenGrant( } const scopeString = requestedScopes.join(" "); + const roles = await getUserRoleKeys(user.id, client.id); // Generate new access token const accessToken = await signAccessToken({ sub: user.id, client_id: client.id, scope: scopeString, + roles, }); // Rotate refresh token diff --git a/app/api/oauth/userinfo/route.ts b/app/api/oauth/userinfo/route.ts index 113bbb4..4db19c0 100644 --- a/app/api/oauth/userinfo/route.ts +++ b/app/api/oauth/userinfo/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { oauthConsents, users } from "@/lib/db/schema"; import { verifyAccessToken } from "@/lib/oauth/jwt"; +import { getUserRoleKeys } from "@/lib/oauth/roles"; import { and, eq } from "drizzle-orm"; async function handleUserInfo(request: NextRequest) { @@ -48,6 +49,7 @@ async function handleUserInfo(request: NextRequest) { ), }); const grantedScopes: string[] = consent ? JSON.parse(consent.scopes) : []; + const roles = await getUserRoleKeys(user.id, payload.client_id); // Build response - always include profile, email requires scope from DB const response: Record = { @@ -56,6 +58,7 @@ async function handleUserInfo(request: NextRequest) { name: user.displayName, preferred_username: user.username, picture: user.avatarUrl || `${process.env.OAUTH_ISSUER_URL}/api/avatar/${user.avatarSeed || user.id}`, + roles, }; if (grantedScopes.includes("email")) { diff --git a/components/admin/client-detail-tabs.tsx b/components/admin/client-detail-tabs.tsx index 9b65e76..a01202b 100644 --- a/components/admin/client-detail-tabs.tsx +++ b/components/admin/client-detail-tabs.tsx @@ -9,8 +9,10 @@ import { IconUpload } from "@/components/admin/icon-upload"; import { ClientSignInSettings } from "@/components/admin/client-sign-in-settings"; import { ClientDangerZone } from "@/components/admin/client-danger-zone"; import { ClientAppIdsCard } from "@/components/admin/client-app-ids-card"; +import { ClientRolesCard } from "@/components/admin/client-roles-card"; import { OAuthEndpointsCard } from "@/components/admin/oauth-endpoints-card"; import type { + OAuthClientRole, OAuthClientEmailWhitelist, OAuthClientAppId, } from "@/lib/db/schema"; @@ -28,6 +30,7 @@ interface ClientDetailTabsProps { }; whitelistEmails: OAuthClientEmailWhitelist[]; appIds: OAuthClientAppId[]; + roles: OAuthClientRole[]; endpoints: { issuer: string; authorizationEndpoint: string; @@ -44,7 +47,7 @@ const tabs = [ type TabId = (typeof tabs)[number]["id"]; -export function ClientDetailTabs({ client, whitelistEmails, appIds, endpoints }: ClientDetailTabsProps) { +export function ClientDetailTabs({ client, whitelistEmails, appIds, roles, endpoints }: ClientDetailTabsProps) { const [activeTab, setActiveTab] = useState("general"); return ( @@ -129,6 +132,7 @@ export function ClientDetailTabs({ client, whitelistEmails, appIds, endpoints }: initialPermission={client.signInPermission as "all" | "none" | "whitelist"} initialEmails={whitelistEmails} /> + )} diff --git a/components/admin/client-roles-card.tsx b/components/admin/client-roles-card.tsx new file mode 100644 index 0000000..4ace452 --- /dev/null +++ b/components/admin/client-roles-card.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { motion } from "framer-motion"; +import { Loader2, Plus, Save, Shield, Trash2 } from "lucide-react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + createClientRole, + deleteClientRole, + updateClientRole, +} from "@/actions/admin/clients/roles"; +import type { OAuthClientRole } from "@/lib/db/schema"; + +interface ClientRolesCardProps { + clientId: string; + initialRoles: OAuthClientRole[]; +} + +export function ClientRolesCard({ + clientId, + initialRoles, +}: ClientRolesCardProps) { + const [roles, setRoles] = useState(initialRoles); + const [newKey, setNewKey] = useState(""); + const [newName, setNewName] = useState(""); + const [drafts, setDrafts] = useState(() => + Object.fromEntries( + initialRoles.map((role) => [ + role.id, + { + key: role.key, + name: role.name, + }, + ]) + ) + ); + const [pendingRoleId, setPendingRoleId] = useState(null); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const handleCreate = (event: React.FormEvent) => { + event.preventDefault(); + const key = newKey.trim().toLowerCase(); + const name = newName.trim(); + if (!key || !name) return; + + setError(null); + startTransition(async () => { + const result = await createClientRole({ clientId, key, name }); + if (result.success && result.role) { + setRoles([...roles, result.role].sort((a, b) => a.name.localeCompare(b.name))); + setDrafts({ + ...drafts, + [result.role.id]: { key: result.role.key, name: result.role.name }, + }); + setNewKey(""); + setNewName(""); + } else { + setError(result.error || "Failed to create role"); + } + }); + }; + + const handleSave = (role: OAuthClientRole) => { + const draft = drafts[role.id]; + if (!draft) return; + + setError(null); + setPendingRoleId(role.id); + startTransition(async () => { + const result = await updateClientRole({ + roleId: role.id, + clientId, + key: draft.key.trim().toLowerCase(), + name: draft.name.trim(), + }); + + if (result.success && result.role) { + setRoles( + roles + .map((existing) => + existing.id === result.role!.id ? result.role! : existing + ) + .sort((a, b) => a.name.localeCompare(b.name)) + ); + setDrafts({ + ...drafts, + [result.role.id]: { + key: result.role.key, + name: result.role.name, + }, + }); + } else { + setError(result.error || "Failed to update role"); + } + setPendingRoleId(null); + }); + }; + + const handleDelete = (role: OAuthClientRole) => { + if (!confirm(`Delete role "${role.name}"? This removes it from assigned users.`)) { + return; + } + + setError(null); + setPendingRoleId(role.id); + startTransition(async () => { + const result = await deleteClientRole(role.id, clientId); + if (result.success) { + setRoles(roles.filter((existing) => existing.id !== role.id)); + const nextDrafts = { ...drafts }; + delete nextDrafts[role.id]; + setDrafts(nextDrafts); + } else { + setError(result.error || "Failed to delete role"); + } + setPendingRoleId(null); + }); + }; + + const isCreating = isPending && pendingRoleId === null; + + return ( + + + + + App Roles + + + Define roles for this app. Users can be assigned these roles from the + user management page. + + + + {error && ( + + {error} + + )} + +
+ setNewName(event.target.value)} + placeholder="Admin" + disabled={isPending} + data-testid="new-client-role-name" + /> + setNewKey(event.target.value)} + placeholder="admin" + disabled={isPending} + data-testid="new-client-role-key" + className="font-mono" + /> + +
+ + {roles.length === 0 ? ( +

+ No roles configured yet +

+ ) : ( +
+ {roles.map((role) => { + const draft = drafts[role.id] ?? { + key: role.key, + name: role.name, + }; + const hasChanges = + draft.key !== role.key || draft.name !== role.name; + const isRolePending = pendingRoleId === role.id; + + return ( +
+ + setDrafts({ + ...drafts, + [role.id]: { ...draft, name: event.target.value }, + }) + } + disabled={isPending} + data-testid={`client-role-name-${role.key}`} + /> + + setDrafts({ + ...drafts, + [role.id]: { ...draft, key: event.target.value }, + }) + } + disabled={isPending} + data-testid={`client-role-key-${role.key}`} + className="font-mono" + /> + + +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/components/admin/user-list.tsx b/components/admin/user-list.tsx index 99dfa32..1a576c2 100644 --- a/components/admin/user-list.tsx +++ b/components/admin/user-list.tsx @@ -16,17 +16,20 @@ import { UserSheet } from "@/components/admin/user-sheet"; import { getUsers } from "@/actions/admin/users/list"; import { useDebounce } from "@/hooks/use-debounce"; import type { User } from "@/lib/db/schema"; +import type { UserRoleOptionApp } from "@/components/admin/user-role-assignments"; interface UserListProps { initialUsers: User[]; initialCursor: string | null; totalCount: number; + roleOptions: UserRoleOptionApp[]; } export function UserList({ initialUsers, initialCursor, totalCount: initialTotalCount, + roleOptions, }: UserListProps) { const [users, setUsers] = useState(initialUsers); const [cursor, setCursor] = useState(initialCursor); @@ -243,6 +246,7 @@ export function UserList({ user={editingUser} open={sheetOpen} onOpenChange={setSheetOpen} + roleOptions={roleOptions} onSuccess={ sheetMode === "create" ? handleUserCreated : handleUserUpdated } diff --git a/components/admin/user-role-assignments.tsx b/components/admin/user-role-assignments.tsx new file mode 100644 index 0000000..026ef31 --- /dev/null +++ b/components/admin/user-role-assignments.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { motion } from "framer-motion"; +import { Loader2, Plus, ShieldCheck, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { getUserRoleAssignments } from "@/actions/admin/users/roles"; +import { Label } from "@/components/ui/label"; + +export interface UserRoleOption { + id: string; + key: string; + name: string; +} + +export interface UserRoleOptionApp { + id: string; + name: string; + roles: UserRoleOption[]; +} + +export interface UserRoleAssignmentValue { + clientId: string; + roleIds: string[]; +} + +interface UserRoleAssignmentsProps { + userId: string; + apps: UserRoleOptionApp[]; + disabled?: boolean; + onChange: (assignments: UserRoleAssignmentValue[]) => void; + onLoadingChange?: (loading: boolean) => void; +} + +export function UserRoleAssignments({ + userId, + apps, + disabled = false, + onChange, + onLoadingChange, +}: UserRoleAssignmentsProps) { + const [rows, setRows] = useState< + { id: string; clientId: string; roleId: string }[] + >([{ id: crypto.randomUUID(), clientId: "", roleId: "" }]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const appsWithRoles = useMemo( + () => apps.filter((app) => app.roles.length > 0), + [apps] + ); + + const emitChange = useCallback( + (nextRows: { id: string; clientId: string; roleId: string }[]) => { + const grouped = new Map(); + for (const row of nextRows) { + if (!row.clientId || !row.roleId) continue; + const roleIds = grouped.get(row.clientId) ?? []; + if (!roleIds.includes(row.roleId)) { + roleIds.push(row.roleId); + } + grouped.set(row.clientId, roleIds); + } + + onChange( + Array.from(grouped.entries()).map(([clientId, roleIds]) => ({ + clientId, + roleIds, + })) + ); + }, + [onChange] + ); + + useEffect(() => { + let cancelled = false; + + getUserRoleAssignments(userId).then((result) => { + if (cancelled) return; + + if (result.success && result.data) { + const loadedRows = result.data.flatMap((assignment) => + assignment.roleIds.map((roleId) => ({ + id: crypto.randomUUID(), + clientId: assignment.clientId, + roleId, + })) + ); + const nextRows = + loadedRows.length > 0 + ? loadedRows + : [{ id: crypto.randomUUID(), clientId: "", roleId: "" }]; + setRows(nextRows); + emitChange(nextRows); + } else { + setError(result.error || "Failed to load user roles"); + } + setIsLoading(false); + onLoadingChange?.(false); + }); + + return () => { + cancelled = true; + }; + }, [userId, emitChange, onLoadingChange]); + + const setNextRows = ( + nextRows: { id: string; clientId: string; roleId: string }[] + ) => { + setRows(nextRows); + emitChange(nextRows); + }; + + const handleClientChange = (rowId: string, clientId: string) => { + const app = appsWithRoles.find((candidate) => candidate.id === clientId); + const nextRows = rows.map((row) => + row.id === rowId + ? { + ...row, + clientId, + roleId: app?.roles.some((role) => role.id === row.roleId) + ? row.roleId + : "", + } + : row + ); + setNextRows(nextRows); + }; + + const handleRoleChange = (rowId: string, roleId: string) => { + setNextRows( + rows.map((row) => (row.id === rowId ? { ...row, roleId } : row)) + ); + }; + + const addRow = () => { + setNextRows([ + ...rows, + { id: crypto.randomUUID(), clientId: "", roleId: "" }, + ]); + }; + + const removeRow = (rowId: string) => { + const nextRows = rows.filter((row) => row.id !== rowId); + setNextRows( + nextRows.length > 0 + ? nextRows + : [{ id: crypto.randomUUID(), clientId: "", roleId: "" }] + ); + }; + + return ( +
+
+ +

App Roles

+
+ + {error && ( + + {error} + + )} + + {isLoading ? ( +
+ + Loading roles +
+ ) : appsWithRoles.length === 0 ? ( +

+ No app roles are configured yet. +

+ ) : ( +
+ {rows.map((row) => { + const selectedApp = appsWithRoles.find( + (app) => app.id === row.clientId + ); + + return ( +
+
+ + +
+
+ + +
+
+ +
+
+ ); + })} + +
+ )} +
+ ); +} diff --git a/components/admin/user-sheet.tsx b/components/admin/user-sheet.tsx index ee7394a..ba8ff98 100644 --- a/components/admin/user-sheet.tsx +++ b/components/admin/user-sheet.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useTransition } from "react"; +import { useCallback, useState, useTransition } from "react"; import { motion } from "framer-motion"; import { Loader2, Mail, Lock, User as UserIcon, AtSign } from "lucide-react"; import { @@ -17,24 +17,38 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { createUser } from "@/actions/admin/users/create"; import { updateUser } from "@/actions/admin/users/update"; +import { setUserRoleAssignments } from "@/actions/admin/users/roles"; import type { User } from "@/lib/db/schema"; +import { + UserRoleAssignments, + type UserRoleAssignmentValue, + type UserRoleOptionApp, +} from "@/components/admin/user-role-assignments"; interface UserSheetProps { mode: "create" | "edit"; user?: User; open: boolean; onOpenChange: (open: boolean) => void; + roleOptions: UserRoleOptionApp[]; onSuccess?: (user?: User) => void; } interface UserFormProps { mode: "create" | "edit"; user?: User; + roleOptions: UserRoleOptionApp[]; onSuccess?: (user?: User) => void; onOpenChange: (open: boolean) => void; } -function UserForm({ mode, user, onSuccess, onOpenChange }: UserFormProps) { +function UserForm({ + mode, + user, + roleOptions, + onSuccess, + onOpenChange, +}: UserFormProps) { const [email, setEmail] = useState( mode === "edit" && user ? user.email : "" ); @@ -48,9 +62,20 @@ function UserForm({ mode, user, onSuccess, onOpenChange }: UserFormProps) { const [emailVerified, setEmailVerified] = useState( mode === "edit" && user ? (user.emailVerified ?? false) : false ); + const [roleAssignments, setRoleAssignments] = useState< + UserRoleAssignmentValue[] + >([]); + const [rolesLoading, setRolesLoading] = useState(mode === "edit"); const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); + const handleRoleAssignmentsChange = useCallback( + (assignments: UserRoleAssignmentValue[]) => { + setRoleAssignments(assignments); + }, + [] + ); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -72,6 +97,11 @@ function UserForm({ mode, user, onSuccess, onOpenChange }: UserFormProps) { setError(result.error || "Failed to create user"); } } else if (user) { + if (rolesLoading) { + setError("Wait for roles to finish loading"); + return; + } + const result = await updateUser(user.id, { email: email !== user.email ? email : undefined, password: password || null, @@ -80,12 +110,23 @@ function UserForm({ mode, user, onSuccess, onOpenChange }: UserFormProps) { emailVerified, }); - if (result.success) { - onOpenChange(false); - onSuccess?.(result.user); - } else { + if (!result.success) { setError(result.error || "Failed to update user"); + return; + } + + const roleResult = await setUserRoleAssignments({ + userId: user.id, + assignments: roleAssignments, + }); + + if (!roleResult.success) { + setError(roleResult.error || "Failed to update user roles"); + return; } + + onOpenChange(false); + onSuccess?.(result.user); } }); }; @@ -198,10 +239,20 @@ function UserForm({ mode, user, onSuccess, onOpenChange }: UserFormProps) { /> + {mode === "edit" && user && ( + + )} +