From e4796b8110070776120cfe3b246634e067059f1c Mon Sep 17 00:00:00 2001 From: No767 <73260931+No767@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:54:32 -0700 Subject: [PATCH 1/3] finish settings page --- package.json | 1 + pnpm-lock.yaml | 103 +-- src/lib/ory.ts | 27 +- src/routeTree.gen.ts | 42 +- src/routes/dashboard/route.tsx | 1052 +++++++++++++++++++++++- src/routes/dashboard/settings.tsx | 12 + src/routes/dashboard/settings/totp.tsx | 154 ---- src/routes/login.tsx | 390 ++++++--- 8 files changed, 1434 insertions(+), 347 deletions(-) create mode 100644 src/routes/dashboard/settings.tsx delete mode 100644 src/routes/dashboard/settings/totp.tsx diff --git a/package.json b/package.json index df5d08cf..196b1200 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "input-otp": "^1.4.2", "lucide-react": "^1.23.0", "next-themes": "^0.4.6", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-day-picker": "^10.0.1", "react-dom": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a702775..cfa62104 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.8) react: specifier: ^19.2.7 version: 19.2.8 @@ -1299,8 +1302,8 @@ packages: peerDependencies: preact: '>= 10.25.0 || >=11.0.0-0' - '@react-grab/cli@0.1.48': - resolution: {integrity: sha512-KXRZFN0b78BeVa4Tq1FC9kiXPpC5lS4pQp/mvQ1azy9dZUJ3zfc7Ei84+yvGh+WoYdceMCFxXfBp6qhU/G056g==} + '@react-grab/cli@0.1.50': + resolution: {integrity: sha512-Px/Hwhhyk2PubCA4ZaRFsfvwxhbxXsetJyvqC6aFFi8WhJhA+oVC33aTzuAeWmM3fhb4/8ce8YsHXI1d6ChcKg==} hasBin: true '@reduxjs/toolkit@2.12.0': @@ -2162,11 +2165,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.43: - resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.1: resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} engines: {node: '>=6.0.0'} @@ -2177,6 +2175,11 @@ packages: peerDependencies: react: '>=17.0.1' + bippy@0.6.1: + resolution: {integrity: sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ==} + peerDependencies: + react: '>=17.0.1' + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -2189,11 +2192,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.6: - resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.7: resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2473,8 +2471,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - deslop-js@0.9.0: - resolution: {integrity: sha512-A3FRlth+grf6x/v88BKBKx/0uQYcUJEgJOWu//v5Axwi09jT8dX0MzBf9g8rVvbJnn0tto92cmHy5XDylLNYag==} + deslop-js@0.9.1: + resolution: {integrity: sha512-eo3Tn37bqC1mMS3BwsdwRITYgQQQdT30g8SMY9C/zRbMu6ekNf9o7aGNUEkxWiKd1dTND58Sndi5fBlT9V6UvA==} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} @@ -2503,9 +2501,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.393: - resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} - electron-to-chromium@1.5.395: resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} @@ -3532,6 +3527,10 @@ packages: resolution: {integrity: sha512-s1xAicFC3RzkD44SVlXlE5KdQ7ry/0l8KerYHYhZMq0DN6VsnqyHzmSsvG/B21DfXAuIC3wpji1BCe56OweSWg==} engines: {node: ^20.19.0 || >=22.13.0} + oxlint-plugin-react-doctor@0.9.1: + resolution: {integrity: sha512-yCW8USbiuszbVsUMN4fL1iU7mRu3Ae3w96+k/xqCyWvW6bF6DzCMtLy5N/w6WLRX78iQsWxVqW/SEgzRBXLfsA==} + engines: {node: ^20.19.0 || >=22.13.0} + oxlint-tsgolint@7.0.2001: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true @@ -3702,6 +3701,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -3727,8 +3731,8 @@ packages: '@types/react': optional: true - react-doctor@0.9.0: - resolution: {integrity: sha512-9Vt6BLn5EofL7vTXXH5wpnhVnYdm3qsBG6OtdiosMtbWvyf8L5b97++4exTiv/Pk0pY2H8XIhDDwMfjFS1Imnw==} + react-doctor@0.9.1: + resolution: {integrity: sha512-pKCWENXuYZK7UNBHpNLbEBlpy+oVWwPnOxTD2B//gmrQLm+UvIknArzP3IOjNP5Nk1NKCR9uavypFA1QMbYFyg==} engines: {node: ^20.19.0 || >=22.13.0} hasBin: true @@ -3747,8 +3751,8 @@ packages: '@types/react': optional: true - react-grab@0.1.48: - resolution: {integrity: sha512-p3WnmK9LLvXE/c4ITPLlXcP1fkXo2VFEQqK94tIfcHIWKNdqdhYYFyNVioO50HR+uyHIwT63Z4txZDqJlVcD/Q==} + react-grab@0.1.50: + resolution: {integrity: sha512-zRkHKq/8a1msCpEOp8BDROeQZT50m0OH2XPrP6jk5op+JAHrlsm3pj7eAQMOsct87EZDeGNnu4r+sGsJJzyw1Q==} hasBin: true peerDependencies: react: '>=17.0.0' @@ -4478,7 +4482,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.6 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -5335,7 +5339,7 @@ snapshots: '@preact/signals-core': 1.14.4 preact: 10.29.7 - '@react-grab/cli@0.1.48': + '@react-grab/cli@0.1.50': dependencies: agent-install: 0.0.6 commander: 14.0.3 @@ -5819,7 +5823,7 @@ snapshots: '@typescript-eslint/project-service@8.64.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0)': dependencies: '@typescript-eslint/tsconfig-utils': 8.64.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/types': 8.65.0 debug: 4.4.3(supports-color@7.2.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: @@ -6127,14 +6131,16 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.43: {} - baseline-browser-mapping@2.11.1: {} bippy@0.5.43(react@19.2.8): dependencies: react: 19.2.8 + bippy@0.6.1(react@19.2.8): + dependencies: + react: 19.2.8 + body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 @@ -6157,14 +6163,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.6: - dependencies: - baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.393 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.6) - browserslist@4.28.7: dependencies: baseline-browser-mapping: 2.11.1 @@ -6399,7 +6397,7 @@ snapshots: depd@2.0.0: {} - deslop-js@0.9.0: + deslop-js@0.9.1: dependencies: '@oxc-project/types': 0.141.0 fast-glob: 3.3.3 @@ -6430,8 +6428,6 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.393: {} - electron-to-chromium@1.5.395: {} embla-carousel-react@8.6.0(react@19.2.8): @@ -7498,6 +7494,13 @@ snapshots: eslint-visitor-keys: 5.0.1 oxc-parser: 0.141.0 + oxlint-plugin-react-doctor@0.9.1: + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + oxc-parser: 0.141.0 + oxlint-tsgolint@7.0.2001: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2001 @@ -7659,6 +7662,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.8): + dependencies: + react: 19.2.8 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -7683,14 +7690,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-doctor@0.9.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)(supports-color@7.2.0))(oxlint-tsgolint@7.0.2001)(supports-color@7.2.0): + react-doctor@0.9.1(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)(supports-color@7.2.0))(oxlint-tsgolint@7.0.2001)(supports-color@7.2.0): dependencies: '@babel/code-frame': 7.29.7 '@sentry/node': 10.66.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(supports-color@7.2.0) agent-install: 0.0.5 conf: 15.1.0 confbox: 0.2.4 - deslop-js: 0.9.0 + deslop-js: 0.9.1 eslint-plugin-react-hooks: 7.1.1(eslint@10.7.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) figures: 6.1.0 ink: 7.1.1(@types/react@19.2.17)(react@19.2.5) @@ -7698,7 +7705,7 @@ snapshots: jiti: 2.7.0 magicast: 0.5.3 oxlint: 1.74.0(oxlint-tsgolint@7.0.2001) - oxlint-plugin-react-doctor: 0.9.0 + oxlint-plugin-react-doctor: 0.9.1 prompts: 2.4.2 react: 19.2.5 typescript: 5.9.3 @@ -7731,10 +7738,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-grab@0.1.48(react@19.2.8): + react-grab@0.1.50(react@19.2.8): dependencies: - '@react-grab/cli': 0.1.48 - bippy: 0.5.43(react@19.2.8) + '@react-grab/cli': 0.1.50 + bippy: 0.6.1(react@19.2.8) optionalDependencies: react: 19.2.8 @@ -7766,9 +7773,9 @@ snapshots: preact: 10.29.7 prompts: 2.4.2 react: 19.2.8 - react-doctor: 0.9.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)(supports-color@7.2.0))(oxlint-tsgolint@7.0.2001)(supports-color@7.2.0) + react-doctor: 0.9.1(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@types/react@19.2.17)(eslint@10.7.0(jiti@2.7.0)(supports-color@7.2.0))(oxlint-tsgolint@7.0.2001)(supports-color@7.2.0) react-dom: 19.2.8(react@19.2.8) - react-grab: 0.1.48(react@19.2.8) + react-grab: 0.1.50(react@19.2.8) optionalDependencies: unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) transitivePeerDependencies: @@ -8248,12 +8255,6 @@ snapshots: rolldown: 1.1.5 vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) - update-browserslist-db@1.2.3(browserslist@4.28.6): - dependencies: - browserslist: 4.28.6 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: browserslist: 4.28.7 diff --git a/src/lib/ory.ts b/src/lib/ory.ts index 29d22097..e226f0ce 100644 --- a/src/lib/ory.ts +++ b/src/lib/ory.ts @@ -2,12 +2,14 @@ import axios from "axios"; /// Types and Interfaces +type SubmitBody = Partial & { error?: { id?: string }; redirect_browser_to?: string }; export type FlowType = "login" | "registration" | "recovery" | "verification" | "settings"; export type SubmitResponse = - | { kind: "success" } + | { flow?: Flow; kind: "success" } | { flow: Flow; kind: "validation" } | { kind: "redirect"; url: string } + | { kind: "refresh"; url: string } | { kind: "expired" }; export interface Message { @@ -23,7 +25,7 @@ export interface OryNode { id?: string; name?: string; src?: string; - text?: { text?: string }; + text?: { context?: { secrets?: { text?: string }[] }; text?: string }; value?: unknown; }; messages: Message[]; @@ -42,6 +44,13 @@ export const ORY_URL = const ACCEPT_JSON = { headers: { Accept: "application/json" } }; +/// Helper functions + +function toFlow(body: SubmitBody): Flow | undefined { + const { id, return_to, ui } = body; + return id === undefined || ui === undefined ? undefined : { id, return_to, ui }; +} + /// Exported functions export function csrfToken(flow: Flow): string { @@ -73,12 +82,18 @@ export async function orySubmit( action: string, body: Record, ): Promise { - const { status, data } = await axios.post(action, body, { + const { status, data } = await axios.post(action, body, { ...ACCEPT_JSON, validateStatus: () => true, }); + const flow = toFlow(data); - if (status < 300) return { kind: "success" }; - if (data.redirect_browser_to) return { kind: "redirect", url: data.redirect_browser_to }; - return status === 400 ? { flow: data, kind: "validation" } : { kind: "expired" }; + if (status < 300) return { flow, kind: "success" }; + if (data.redirect_browser_to) { + const reason = data.error?.id; + return reason === "session_refresh_required" || reason === "session_aal2_required" + ? { kind: "refresh", url: data.redirect_browser_to } + : { kind: "redirect", url: data.redirect_browser_to }; + } + return status === 400 && flow ? { flow, kind: "validation" } : { kind: "expired" }; } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f2ffb7d9..a9005edd 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as SigsRouteImport } from './routes/sigs' import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' import { Route as DashboardEventsRouteImport } from './routes/dashboard/events' import { Route as DashboardProjectsRouteImport } from './routes/dashboard/projects' +import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settings' import { Route as ProjectProjectIdRouteImport } from './routes/project/$projectId' import { Route as DashboardAdminMembersRouteImport } from './routes/dashboard/admin/members' import { Route as DashboardAdminOverviewRouteImport } from './routes/dashboard/admin/overview' @@ -28,7 +29,6 @@ import { Route as DashboardAdminTagsRouteImport } from './routes/dashboard/admin import { Route as DashboardEventsPastRouteImport } from './routes/dashboard/events_.past' import { Route as DashboardManageEventsRouteImport } from './routes/dashboard/manage/events' import { Route as DashboardManageProjectsRouteImport } from './routes/dashboard/manage/projects' -import { Route as DashboardSettingsTotpRouteImport } from './routes/dashboard/settings/totp' import { Route as DashboardManageProjectsProjectIdGalleryRouteImport } from './routes/dashboard/manage/projects_.$projectId.gallery' const IndexRoute = IndexRouteImport.update({ @@ -91,6 +91,11 @@ const DashboardProjectsRoute = DashboardProjectsRouteImport.update({ path: '/projects', getParentRoute: () => DashboardRouteRoute, } as any) +const DashboardSettingsRoute = DashboardSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => DashboardRouteRoute, +} as any) const ProjectProjectIdRoute = ProjectProjectIdRouteImport.update({ id: '/project/$projectId', path: '/project/$projectId', @@ -126,11 +131,6 @@ const DashboardManageProjectsRoute = DashboardManageProjectsRouteImport.update({ path: '/manage/projects', getParentRoute: () => DashboardRouteRoute, } as any) -const DashboardSettingsTotpRoute = DashboardSettingsTotpRouteImport.update({ - id: '/settings/totp', - path: '/settings/totp', - getParentRoute: () => DashboardRouteRoute, -} as any) const DashboardManageProjectsProjectIdGalleryRoute = DashboardManageProjectsProjectIdGalleryRouteImport.update({ id: '/manage/projects_/$projectId/gallery', @@ -150,6 +150,7 @@ export interface FileRoutesByFullPath { '/sigs': typeof SigsRoute '/dashboard/events': typeof DashboardEventsRoute '/dashboard/projects': typeof DashboardProjectsRoute + '/dashboard/settings': typeof DashboardSettingsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard/': typeof DashboardIndexRoute '/dashboard/admin/members': typeof DashboardAdminMembersRoute @@ -158,7 +159,6 @@ export interface FileRoutesByFullPath { '/dashboard/events/past': typeof DashboardEventsPastRoute '/dashboard/manage/events': typeof DashboardManageEventsRoute '/dashboard/manage/projects': typeof DashboardManageProjectsRoute - '/dashboard/settings/totp': typeof DashboardSettingsTotpRoute '/dashboard/manage/projects/$projectId/gallery': typeof DashboardManageProjectsProjectIdGalleryRoute } export interface FileRoutesByTo { @@ -172,6 +172,7 @@ export interface FileRoutesByTo { '/sigs': typeof SigsRoute '/dashboard/events': typeof DashboardEventsRoute '/dashboard/projects': typeof DashboardProjectsRoute + '/dashboard/settings': typeof DashboardSettingsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard': typeof DashboardIndexRoute '/dashboard/admin/members': typeof DashboardAdminMembersRoute @@ -180,7 +181,6 @@ export interface FileRoutesByTo { '/dashboard/events/past': typeof DashboardEventsPastRoute '/dashboard/manage/events': typeof DashboardManageEventsRoute '/dashboard/manage/projects': typeof DashboardManageProjectsRoute - '/dashboard/settings/totp': typeof DashboardSettingsTotpRoute '/dashboard/manage/projects/$projectId/gallery': typeof DashboardManageProjectsProjectIdGalleryRoute } export interface FileRoutesById { @@ -196,6 +196,7 @@ export interface FileRoutesById { '/sigs': typeof SigsRoute '/dashboard/events': typeof DashboardEventsRoute '/dashboard/projects': typeof DashboardProjectsRoute + '/dashboard/settings': typeof DashboardSettingsRoute '/project/$projectId': typeof ProjectProjectIdRoute '/dashboard/': typeof DashboardIndexRoute '/dashboard/admin/members': typeof DashboardAdminMembersRoute @@ -204,7 +205,6 @@ export interface FileRoutesById { '/dashboard/events_/past': typeof DashboardEventsPastRoute '/dashboard/manage/events': typeof DashboardManageEventsRoute '/dashboard/manage/projects': typeof DashboardManageProjectsRoute - '/dashboard/settings/totp': typeof DashboardSettingsTotpRoute '/dashboard/manage/projects_/$projectId/gallery': typeof DashboardManageProjectsProjectIdGalleryRoute } export interface FileRouteTypes { @@ -221,6 +221,7 @@ export interface FileRouteTypes { | '/sigs' | '/dashboard/events' | '/dashboard/projects' + | '/dashboard/settings' | '/project/$projectId' | '/dashboard/' | '/dashboard/admin/members' @@ -229,7 +230,6 @@ export interface FileRouteTypes { | '/dashboard/events/past' | '/dashboard/manage/events' | '/dashboard/manage/projects' - | '/dashboard/settings/totp' | '/dashboard/manage/projects/$projectId/gallery' fileRoutesByTo: FileRoutesByTo to: @@ -243,6 +243,7 @@ export interface FileRouteTypes { | '/sigs' | '/dashboard/events' | '/dashboard/projects' + | '/dashboard/settings' | '/project/$projectId' | '/dashboard' | '/dashboard/admin/members' @@ -251,7 +252,6 @@ export interface FileRouteTypes { | '/dashboard/events/past' | '/dashboard/manage/events' | '/dashboard/manage/projects' - | '/dashboard/settings/totp' | '/dashboard/manage/projects/$projectId/gallery' id: | '__root__' @@ -266,6 +266,7 @@ export interface FileRouteTypes { | '/sigs' | '/dashboard/events' | '/dashboard/projects' + | '/dashboard/settings' | '/project/$projectId' | '/dashboard/' | '/dashboard/admin/members' @@ -274,7 +275,6 @@ export interface FileRouteTypes { | '/dashboard/events_/past' | '/dashboard/manage/events' | '/dashboard/manage/projects' - | '/dashboard/settings/totp' | '/dashboard/manage/projects_/$projectId/gallery' fileRoutesById: FileRoutesById } @@ -377,6 +377,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardProjectsRouteImport parentRoute: typeof DashboardRouteRoute } + '/dashboard/settings': { + id: '/dashboard/settings' + path: '/settings' + fullPath: '/dashboard/settings' + preLoaderRoute: typeof DashboardSettingsRouteImport + parentRoute: typeof DashboardRouteRoute + } '/project/$projectId': { id: '/project/$projectId' path: '/project/$projectId' @@ -426,13 +433,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardManageProjectsRouteImport parentRoute: typeof DashboardRouteRoute } - '/dashboard/settings/totp': { - id: '/dashboard/settings/totp' - path: '/settings/totp' - fullPath: '/dashboard/settings/totp' - preLoaderRoute: typeof DashboardSettingsTotpRouteImport - parentRoute: typeof DashboardRouteRoute - } '/dashboard/manage/projects_/$projectId/gallery': { id: '/dashboard/manage/projects_/$projectId/gallery' path: '/manage/projects/$projectId/gallery' @@ -446,6 +446,7 @@ declare module '@tanstack/react-router' { interface DashboardRouteRouteChildren { DashboardEventsRoute: typeof DashboardEventsRoute DashboardProjectsRoute: typeof DashboardProjectsRoute + DashboardSettingsRoute: typeof DashboardSettingsRoute DashboardIndexRoute: typeof DashboardIndexRoute DashboardAdminMembersRoute: typeof DashboardAdminMembersRoute DashboardAdminOverviewRoute: typeof DashboardAdminOverviewRoute @@ -453,13 +454,13 @@ interface DashboardRouteRouteChildren { DashboardEventsPastRoute: typeof DashboardEventsPastRoute DashboardManageEventsRoute: typeof DashboardManageEventsRoute DashboardManageProjectsRoute: typeof DashboardManageProjectsRoute - DashboardSettingsTotpRoute: typeof DashboardSettingsTotpRoute DashboardManageProjectsProjectIdGalleryRoute: typeof DashboardManageProjectsProjectIdGalleryRoute } const DashboardRouteRouteChildren: DashboardRouteRouteChildren = { DashboardEventsRoute: DashboardEventsRoute, DashboardProjectsRoute: DashboardProjectsRoute, + DashboardSettingsRoute: DashboardSettingsRoute, DashboardIndexRoute: DashboardIndexRoute, DashboardAdminMembersRoute: DashboardAdminMembersRoute, DashboardAdminOverviewRoute: DashboardAdminOverviewRoute, @@ -467,7 +468,6 @@ const DashboardRouteRouteChildren: DashboardRouteRouteChildren = { DashboardEventsPastRoute: DashboardEventsPastRoute, DashboardManageEventsRoute: DashboardManageEventsRoute, DashboardManageProjectsRoute: DashboardManageProjectsRoute, - DashboardSettingsTotpRoute: DashboardSettingsTotpRoute, DashboardManageProjectsProjectIdGalleryRoute: DashboardManageProjectsProjectIdGalleryRoute, } diff --git a/src/routes/dashboard/route.tsx b/src/routes/dashboard/route.tsx index 43bc8bf9..493cd3c4 100644 --- a/src/routes/dashboard/route.tsx +++ b/src/routes/dashboard/route.tsx @@ -1,3 +1,4 @@ +import { useForm } from "@tanstack/react-form"; import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, @@ -6,25 +7,67 @@ import { redirect, useLocation, useMatches, + useNavigate, + useRouter, } from "@tanstack/react-router"; import axios from "axios"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; import { type LucideIcon, Calendar, + Check, ChevronRight, + Copy, + Download, Folder, Gauge, History, Home, + KeyRound, + Lock, + LogOut, Settings, + Shield, + ShieldCheck, + ShieldOff, Tag, + Trash2, + TriangleAlert, Unlock, + User, Users, + X, } from "lucide-react"; -import { type CSSProperties, useCallback, useMemo, useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { + type CSSProperties, + type ChangeEvent, + type MouseEvent, + type SyntheticEvent, + Fragment, + useCallback, + useMemo, + useState, +} from "react"; +import { toast } from "sonner"; +import { z } from "zod"; import { Footer } from "@/components/app/footer"; +import { type PendingSudo, SudoDialog } from "@/components/app/sudo-dialog"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; +import { Label } from "@/components/ui/label"; import { Sidebar, SidebarContent, @@ -35,16 +78,21 @@ import { SidebarHeader, SidebarInset, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; +import { type Flow, type SubmitResponse, csrfToken, oryInit, orySubmit } from "@/lib/ory"; import { cn } from "@/lib/utils"; export const Route = createFileRoute("/dashboard")({ component: DashboardLayout, + validateSearch: (search: Record): { settings?: "profile" } => ({ + settings: search.settings === "profile" ? "profile" : undefined, + }), loader: async ({ context: { queryClient } }) => { await queryClient.ensureQueryData(meQueryOptions).catch(() => { redirect({ to: "/login", search: {}, throw: true }); @@ -54,6 +102,10 @@ export const Route = createFileRoute("/dashboard")({ /// Types and Interfaces +type AccountField = "display_name" | "password"; +type TotpStep = "scan" | "verify" | "backup"; +type SettingsSection = "account" | "security"; + export type Role = "admin" | "leads" | "manager" | "root"; export interface ClientSession { @@ -105,6 +157,22 @@ interface DashboardStaticData { home?: boolean; } +interface SettingsSectionItem { + value: SettingsSection; + label: string; + icon: LucideIcon; +} + +interface TotpEnrollInput { + code: string; + enrollFlow: Flow; +} + +interface TotpStepItem { + value: TotpStep; + label: string; +} + /// Constants const EMPTY_STATIC: DashboardStaticData = {}; @@ -166,6 +234,17 @@ const ADMIN_NAV_ITEMS: NavItem[] = [ }, ]; +const SETTINGS_SECTIONS: SettingsSectionItem[] = [ + { value: "account", label: "Account", icon: User }, + { value: "security", label: "Security", icon: Shield }, +]; + +const TOTP_STEPS: TotpStepItem[] = [ + { value: "scan", label: "Scan" }, + { value: "verify", label: "Verify" }, + { value: "backup", label: "Backup codes" }, +]; + export const ROLES_BY_RANK: Role[] = ["root", "admin", "manager", "leads"]; const API_BASE_URL = @@ -184,6 +263,65 @@ const SHARED_QUERY_OPTIONS = { refetchOnMount: false, } as const; +const OPEN_SETTINGS = (): { settings: "profile" } => ({ settings: "profile" }); +const SETTINGS_MASK = { to: "/dashboard/settings" } as const; +const BLANK_DELETE_FORM = { email: "" }; +const TOTP_PARAMS = { algorithm: "SHA1", digits: "6", issuer: TOTP_ISSUER, period: "30" }; +const TOTP_ISSUER = "Kanae"; +const BACKUP_CODES_FILE = "ucmacm-backup-codes.txt"; +const OTP_SLOT_CLASS = "size-11 rounded-md border-l border-border bg-background text-lg"; +const STEP_LABEL_CLASS = "inline-flex items-center gap-1.5 text-[12px] font-bold"; +const STEP_BADGE_CLASS = + "inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[11px]"; +const STEP_LINE_CLASS = "h-0.5 flex-1 rounded-full"; +const EMPTY_CODES: string[] = []; +const FIELD_ERROR_CLASS = "text-[12px] font-semibold text-[#e13737] dark:text-[#ff6b6b]"; + +const RAIL_STYLE = { "--sidebar-width": "13.125rem" } as CSSProperties; +const RAIL_BUTTON_CLASS = + "h-auto gap-3 rounded-xl border border-transparent px-3 py-2.75 text-[14.5px] font-semibold text-brand-text-sub data-active:border-brand-teal/45 data-active:bg-brand-teal/14 data-active:font-bold data-active:text-foreground [&_svg]:size-4.75"; +const RAIL_MARKER_CLASS = "absolute top-2 -left-3 bottom-2 w-1 rounded-[9px] bg-brand-sky"; +const RAIL_ICON_CLASS = + "flex size-7.5 shrink-0 items-center justify-center rounded-[9px] group-data-active/menu-button:bg-card group-data-active/menu-button:text-[#078c79] group-data-active/menu-button:ring-1 group-data-active/menu-button:ring-border dark:group-data-active/menu-button:text-[#2fead0]"; +const PANEL_CLASS = + "min-w-0 flex-1 overflow-y-auto bg-background px-7.5 py-6.5 [scrollbar-color:auto] [&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-3 [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-muted-foreground/40 [&::-webkit-scrollbar-thumb]:bg-clip-content [&::-webkit-scrollbar-thumb:hover]:bg-muted-foreground/65 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2.5"; +const PANEL_HEADING_CLASS = "text-2xl font-bold tracking-tight text-foreground"; +const PANEL_RULE_CLASS = "mt-2.25 block h-1.25 w-14 rounded-[9px] bg-brand-sky"; +const PANEL_SUB_CLASS = "mt-3 mb-6 text-[13.5px] text-muted-foreground"; +const FORM_ROW_CLASS = + "grid items-center gap-5 rounded-[14px] border border-border bg-card px-5 py-4.5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)]"; +const SECTION_TITLE_CLASS = "text-base font-extrabold text-foreground"; +const SECTION_SUB_CLASS = "mt-0.75 mb-4 text-[13px] text-muted-foreground"; +const ROW_TITLE_CLASS = "block text-base font-extrabold text-foreground"; +const SECURITY_ROW_CLASS = + "flex items-center gap-3.5 rounded-[14px] border border-border bg-card px-5 py-4.5"; +const SECURITY_ICON_CLASS = + "flex size-11 shrink-0 items-center justify-center rounded-xl border border-border"; +const SECURITY_ROW_TITLE_CLASS = + "flex flex-wrap items-center gap-2 text-[15px] font-extrabold text-foreground"; +const SECURITY_ROW_SUB_CLASS = "mt-1 text-[12.5px]/relaxed text-muted-foreground"; +const ROW_SUB_CLASS = "mt-1.25 text-[12.5px]/relaxed text-muted-foreground"; +const INPUT_CLASS = "h-11 rounded-xl border-border bg-background px-3.25 text-[15px]"; +const TEAL_BUTTON_CLASS = + "h-11 rounded-full bg-brand-teal px-5.5 text-[15px] font-bold text-primary shadow-[0px_4px_14px_rgba(112,144,176,0.14)] hover:bg-brand-teal/90"; +const SIGN_OUT_BUTTON_CLASS = + "font-bold text-brand-text-sub hover:bg-destructive/8 hover:text-destructive/80 hover:ring-1 hover:ring-destructive/20 hover:shadow-[0_0_16px_-6px_#e1373759] dark:hover:shadow-[0_0_16px_-6px_#ff6b6b45]"; +const DANGER_BUTTON_CLASS = + "h-10 rounded-full border-destructive/55 bg-transparent px-5 font-bold text-destructive hover:bg-destructive/10"; + +/// Zod schemas + +const accountSchema = z.object({ + display_name: z + .string() + .trim() + .min(1, "Enter a display name.") + .max(64, "Keep it under 64 characters."), + password: z + .string() + .refine((value) => value === "" || value.length >= 16, "Use at least 16 characters."), +}); + /// Tanstack Query options export const meQueryOptions = queryOptions({ @@ -204,6 +342,14 @@ export const sudoQueryOptions = queryOptions({ ...SHARED_QUERY_OPTIONS, }); +export const settingsFlowQueryOptions = queryOptions({ + queryKey: ["ory", "settings"], + queryFn: async () => { + return await oryInit("settings", {}); + }, + ...SHARED_QUERY_OPTIONS, +}); + /// Helper functions export function isSudoActive(sudo: Sudo | undefined): boolean { @@ -245,13 +391,23 @@ function SidebarNav({ items, pathname }: Readonly<{ items: NavItem[]; pathname: /// Route function DashboardLayout() { + const navigate = useNavigate(); + const router = useRouter(); const queryClient = useQueryClient(); const { data: me } = useQuery(meQueryOptions); const { data: sudo } = useQuery(sudoQueryOptions); + const { data: flow } = useQuery(settingsFlowQueryOptions); const { pathname } = useLocation(); + const { settings } = Route.useSearch(); const meta = useMatches({ select: (matches) => matches.at(-1)?.staticData ?? EMPTY_STATIC }); const [collapsed, setCollapsed] = useState(false); + const [section, setSection] = useState("account"); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [pendingSudo, setPendingSudo] = useState(); + const [totpStep, setTotpStep] = useState(); + const [totpCode, setTotpCode] = useState(""); + const [backupFlow, setBackupFlow] = useState(); const { mutate: revokeSudo } = useMutation({ mutationFn: async () => { @@ -265,12 +421,355 @@ function DashboardLayout() { revokeSudo(); }, [revokeSudo]); + const { mutate: signOut, isPending: signingOut } = useMutation({ + mutationFn: async () => { + await axios.post(`${API_BASE_URL}/members/logout`); + }, + onSuccess: async () => { + queryClient.clear(); + await navigate({ to: "/login", search: {} }); + }, + onError: () => toast.error("Couldn't sign you out. Please try again."), + }); + + const followOry = useCallback( + async (result: SubmitResponse) => { + if (result.kind !== "redirect" && result.kind !== "refresh") return false; + const target = new URL(result.url); + if (result.kind === "refresh") { + // Kanae should be updated to use /dashboard/settings instead + target.searchParams.set("return_to", `${globalThis.location.origin}/dashboard/settings`); + toast.info( + "Your sign-in is too old to change security settings. Sign in again to continue.", + ); + } + await navigate({ href: target.href }); + return true; + }, + [navigate], + ); + const { mutate: enrollTotp, isPending: enrolling } = useMutation({ + mutationFn: async ({ code, enrollFlow }: TotpEnrollInput) => { + const enrolled = await orySubmit(enrollFlow.ui.action, { + method: "totp", + totp_code: code, + csrf_token: csrfToken(enrollFlow), + }); + if (enrolled.kind !== "success" || !enrolled.flow) return enrolled; + return orySubmit(enrolled.flow.ui.action, { + method: "lookup_secret", + lookup_secret_regenerate: true, + csrf_token: csrfToken(enrolled.flow), + }); + }, + onSuccess: async (result) => { + if (await followOry(result)) return; + if (result.kind !== "success" || !result.flow) { + toast.error( + result.kind === "validation" + ? (result.flow.ui.messages?.at(0)?.text ?? "That code didn't match. Try the next one.") + : "That setup session expired. Close this and start again.", + ); + setTotpCode(""); + await queryClient.invalidateQueries({ queryKey: settingsFlowQueryOptions.queryKey }); + return; + } + setBackupFlow(result.flow); + setTotpCode(""); + setTotpStep("backup"); + }, + onError: () => toast.error("Couldn't finish setup. Please try again."), + }); + + const { mutate: regenerateBackup, isPending: regenerating } = useMutation({ + mutationFn: async () => { + const fresh = await oryInit("settings", {}); + return orySubmit(fresh.ui.action, { + method: "lookup_secret", + lookup_secret_regenerate: true, + csrf_token: csrfToken(fresh), + }); + }, + onSuccess: async (result) => { + if (await followOry(result)) return; + if (result.kind !== "success" || !result.flow) { + toast.error("Couldn't generate new backup codes. Please try again."); + return; + } + setBackupFlow(result.flow); + setTotpStep("backup"); + }, + onError: () => toast.error("Couldn't generate new backup codes. Please try again."), + }); + + const { mutate: confirmBackup, isPending: confirmingBackup } = useMutation({ + mutationFn: async (pendingFlow: Flow) => + orySubmit(pendingFlow.ui.action, { + method: "lookup_secret", + lookup_secret_confirm: true, + csrf_token: csrfToken(pendingFlow), + }), + onSuccess: async (result) => { + if (await followOry(result)) return; + if (result.kind !== "success") { + toast.error("Couldn't save those backup codes. Please try again."); + return; + } + setTotpStep(undefined); + setBackupFlow(undefined); + toast.success("Two-factor authentication is on."); + await queryClient.invalidateQueries({ queryKey: settingsFlowQueryOptions.queryKey }); + await queryClient.invalidateQueries({ queryKey: meQueryOptions.queryKey }); + }, + onError: () => toast.error("Couldn't save those backup codes. Please try again."), + }); + + const { mutate: disableTotp, isPending: disabling } = useMutation({ + mutationFn: async () => { + const fresh = await oryInit("settings", {}); + return orySubmit(fresh.ui.action, { + method: "totp", + totp_unlink: true, + csrf_token: csrfToken(fresh), + }); + }, + onSuccess: async (result) => { + if (await followOry(result)) return; + if (result.kind !== "success") { + toast.error("Couldn't turn off two-factor. Please try again."); + return; + } + toast.success("Two-factor authentication is off."); + await queryClient.invalidateQueries({ queryKey: settingsFlowQueryOptions.queryKey }); + await queryClient.invalidateQueries({ queryKey: meQueryOptions.queryKey }); + }, + onError: () => toast.error("Couldn't turn off two-factor. Please try again."), + }); + + const { mutate: deleteAccount, isPending: deleting } = useMutation({ + mutationFn: async () => { + await axios.delete(`${API_BASE_URL}/members/me`); + }, + onSuccess: async () => { + queryClient.clear(); + toast.success("Your account has been deleted."); + await navigate({ to: "/" }); + }, + onError: () => toast.error("Couldn't delete your account. Please try again."), + }); + + const deleteSchema = useMemo( + () => + z.object({ + email: z + .email("Enter a valid email address.") + .refine( + (value) => value.trim().toLowerCase() === me?.email.toLowerCase(), + "That isn't this account's email address.", + ), + }), + [me], + ); + const accountForm = useForm({ + defaultValues: { display_name: me?.display_name ?? me?.name ?? "", password: "" }, + validators: { onChange: accountSchema }, + onSubmit: async ({ value }) => { + if (!me || !flow) return; + const profile = await orySubmit(flow.ui.action, { + method: "profile", + csrf_token: csrfToken(flow), + traits: { + email: me.email, + name: me.name, + display_name: value.display_name.trim() || undefined, + }, + }); + const passwordFlow = + profile.kind === "success" && value.password ? await oryInit("settings", {}) : undefined; + const response = passwordFlow + ? await orySubmit(passwordFlow.ui.action, { + method: "password", + csrf_token: csrfToken(passwordFlow), + password: value.password, + }) + : profile; + + if (response.kind === "redirect") { + toast.error( + "Your session needs a refresh to change credentials — log out, log back in, then retry within 15 minutes.", + ); + return; + } + if (response.kind !== "success") { + toast.error( + response.kind === "validation" + ? (response.flow.ui.messages?.at(0)?.text ?? "Couldn't save those changes.") + : "That settings session expired. Reopen settings and try again.", + ); + await queryClient.invalidateQueries({ queryKey: settingsFlowQueryOptions.queryKey }); + return; + } + accountForm.reset({ display_name: value.display_name.trim(), password: "" }); + toast.success("Account updated."); + await queryClient.invalidateQueries({ queryKey: meQueryOptions.queryKey }); + await queryClient.invalidateQueries({ queryKey: settingsFlowQueryOptions.queryKey }); + }, + }); + + const deleteForm = useForm({ + defaultValues: BLANK_DELETE_FORM, + validators: { onChange: deleteSchema }, + onSubmit: () => { + if (isSudoActive(sudo)) { + deleteAccount(); + return; + } + setPendingSudo({ + title: "Delete your own account", + detail: + "This wipes your sessions, and purges all data assoicated with you. There is no undo and no recovery.", + run: () => { + deleteAccount(); + }, + }); + }, + }); + const handleOpenChange = useCallback((open: boolean) => { setCollapsed(!open); }, []); const toggleSidebar = useCallback(() => { setCollapsed((value) => !value); }, []); + const closeSettings = useCallback(() => { + router.history.back(); + }, [router]); + const handleSettingsOpenChange = useCallback( + (open: boolean) => { + if (!open) closeSettings(); + }, + [closeSettings], + ); + const selectSection = useCallback((event: MouseEvent) => { + setSection(event.currentTarget.dataset.section as SettingsSection); + }, []); + const handleAccountText = useCallback( + (event: ChangeEvent) => { + const { name, value } = event.target; + accountForm.setFieldValue(name as AccountField, value); + }, + [accountForm], + ); + const submitAccount = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + void accountForm.handleSubmit(); + }, + [accountForm], + ); + const handleDeleteText = useCallback( + (event: ChangeEvent) => { + deleteForm.setFieldValue("email", event.target.value); + }, + [deleteForm], + ); + const submitDelete = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + void deleteForm.handleSubmit(); + }, + [deleteForm], + ); + const startDelete = useCallback(() => { + setConfirmingDelete(true); + }, []); + const cancelDelete = useCallback(() => { + setConfirmingDelete(false); + deleteForm.reset(); + }, [deleteForm]); + const submitSignOut = useCallback(() => { + signOut(); + }, [signOut]); + const clearSudo = useCallback(() => { + setPendingSudo(undefined); + }, []); + + const startTotp = useCallback(() => { + setTotpCode(""); + setTotpStep("scan"); + }, []); + const goToVerify = useCallback(() => { + setTotpStep("verify"); + }, []); + const submitTotpCode = useCallback(() => { + if (flow && totpCode.length === 6) enrollTotp({ code: totpCode, enrollFlow: flow }); + }, [flow, totpCode, enrollTotp]); + const submitBackupConfirm = useCallback(() => { + if (backupFlow) confirmBackup(backupFlow); + }, [backupFlow, confirmBackup]); + const closeTotp = useCallback(() => { + if (backupFlow) toast.warning("Backup codes discarded — regenerate them from Security."); + setTotpStep(undefined); + setBackupFlow(undefined); + setTotpCode(""); + }, [backupFlow]); + const handleTotpOpenChange = useCallback( + (open: boolean) => { + if (!open) closeTotp(); + }, + [closeTotp], + ); + const startRegenerate = useCallback(() => { + if (isSudoActive(sudo)) { + regenerateBackup(); + return; + } + setPendingSudo({ + title: "Regenerate backup codes", + detail: "The codes you have now stop working the moment new ones are issued.", + run: () => { + regenerateBackup(); + }, + }); + }, [sudo, regenerateBackup]); + const startDisableTotp = useCallback(() => { + if (isSudoActive(sudo)) { + disableTotp(); + return; + } + setPendingSudo({ + title: "Turn off two-factor authentication", + detail: "Your account drops back to a password alone, and sudo stops being available.", + run: () => { + disableTotp(); + }, + }); + }, [sudo, disableTotp]); + + const backupCodes = + backupFlow?.ui.nodes + .find((node) => node.attributes.id === "lookup_secret_codes") + ?.attributes.text?.context?.secrets?.map((secret) => secret.text ?? "") ?? EMPTY_CODES; + const copyBackupCodes = useCallback(() => { + navigator.clipboard + .writeText(backupCodes.join("\n")) + .then(() => { + toast.success("Backup codes copied."); + }) + .catch(() => { + toast.error("Couldn't copy the backup codes."); + }); + }, [backupCodes]); + + const totpEnrolled = + flow?.ui.nodes.some((node) => node.attributes.name === "totp_unlink") ?? false; + const totpSecret = flow?.ui.nodes.find((node) => node.attributes.id === "totp_secret_key") + ?.attributes.text?.text; + const totpLabel = encodeURIComponent(`${TOTP_ISSUER}:${me?.email ?? ""}`); + const totpQuery = new URLSearchParams({ ...TOTP_PARAMS, secret: totpSecret ?? "" }); + const totpUri = `otpauth://totp/${totpLabel}?${totpQuery.toString()}`; + const totpIndex = TOTP_STEPS.findIndex((item) => item.value === totpStep); + const backupCodesHref = `data:text/plain;charset=utf-8,${encodeURIComponent(backupCodes.join("\n"))}`; const access = ROLES_BY_RANK.find((role) => me?.roles.includes(role)); const accessLabel = access ? ROLE_META[access].label : "Member"; @@ -366,12 +865,13 @@ function DashboardLayout() { )} - + + {initials} @@ -386,8 +886,16 @@ function DashboardLayout() { {me?.display_name ? ` · ${me.display_name}` : ""} - - + + } + /> + + + } /> @@ -419,6 +927,538 @@ function DashboardLayout() {