Initial server: Deno/Hono backend with auth, CSRF, Hydra consent, and flow proxy

Hono app serving as the login UI and admin panel for Ory Kratos + Hydra.
Handles OIDC consent/login flows, session management, avatar uploads,
and proxies Kratos admin/public APIs.
This commit is contained in:
2026-03-21 15:17:56 +00:00
commit 1467a948d0
65 changed files with 5525 additions and 0 deletions

10
ui/cunningham.ts Normal file
View File

@@ -0,0 +1,10 @@
export default {
themes: {
default: {},
dark: {},
"dsfr-light": {},
"dsfr-dark": {},
"anct-light": {},
"anct-dark": {},
},
};

16
ui/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ysabeau:ital,wght@0,1..1000;1,1..1000&display=swap" />
<title>Sunbeam Studios</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
ui/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "kratos-admin-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@gouvfr-lasuite/cunningham-react": "^4.2.0",
"@gouvfr-lasuite/ui-kit": "^0.19.9",
"@rjsf/core": "^6.3.1",
"@rjsf/utils": "^6.3.1",
"@rjsf/validator-ajv8": "^6.3.1",
"@tanstack/react-query": "^5.59.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.3.0"
}
}

13
ui/public/favicon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="8" fill="#4F46E5"/>
<g stroke="#4F46E5" stroke-width="2.5" stroke-linecap="round">
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="16" y1="26" x2="16" y2="30"/>
<line x1="2" y1="16" x2="6" y2="16"/>
<line x1="26" y1="16" x2="30" y2="16"/>
<line x1="6.1" y1="6.1" x2="8.9" y2="8.9"/>
<line x1="23.1" y1="23.1" x2="25.9" y2="25.9"/>
<line x1="6.1" y1="25.9" x2="8.9" y2="23.1"/>
<line x1="23.1" y1="8.9" x2="25.9" y2="6.1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 561 B

72
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useCunninghamTheme } from './cunningham/useCunninghamTheme'
import AuthLayout from './layouts/AuthLayout'
import DashboardLayout from './layouts/DashboardLayout'
import LoginPage from './pages/auth/LoginPage'
import RegistrationPage from './pages/auth/RegistrationPage'
import RecoveryPage from './pages/auth/RecoveryPage'
import VerificationPage from './pages/auth/VerificationPage'
import ErrorPage from './pages/auth/ErrorPage'
import ConsentPage from './pages/auth/ConsentPage'
import LogoutPage from './pages/auth/LogoutPage'
import OnboardingWizard from './pages/auth/OnboardingWizard'
import ProfilePage from './pages/settings/profile'
import SecurityPage from './pages/settings/security'
import IdentitiesPage from './pages/identities'
import IdentityCreatePage from './pages/identities/create'
import IdentityDetailPage from './pages/identities/detail'
import IdentityEditPage from './pages/identities/edit'
import SessionsPage from './pages/sessions'
import CourierPage from './pages/courier'
import SchemasPage from './pages/schemas'
const queryClient = new QueryClient()
export default function App() {
const { theme } = useCunninghamTheme()
return (
<CunninghamProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{/* Public auth flows — centered card layout */}
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/registration" element={<RegistrationPage />} />
<Route path="/recovery" element={<RecoveryPage />} />
<Route path="/verification" element={<VerificationPage />} />
<Route path="/error" element={<ErrorPage />} />
</Route>
{/* Hydra flows + onboarding — card layout */}
<Route element={<AuthLayout />}>
<Route path="/consent" element={<ConsentPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/onboarding" element={<OnboardingWizard />} />
</Route>
{/* Authenticated — Dashboard with sidebar */}
<Route element={<DashboardLayout />}>
<Route path="/" element={<Navigate to="/profile" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/security" element={<SecurityPage />} />
{/* Legacy redirect */}
<Route path="/settings" element={<Navigate to="/profile" replace />} />
{/* Admin-only routes */}
<Route path="/identities" element={<IdentitiesPage />} />
<Route path="/identities/create" element={<IdentityCreatePage />} />
<Route path="/identities/:id/edit" element={<IdentityEditPage />} />
<Route path="/identities/:id" element={<IdentityDetailPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/courier" element={<CourierPage />} />
<Route path="/schemas" element={<SchemasPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</CunninghamProvider>
)
}

41
ui/src/api/avatar.ts Normal file
View File

@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function avatarUrl(identityId: string): string {
return `/api/avatar/${identityId}`
}
export function useUploadAvatar() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (file: File) => {
const formData = new FormData()
formData.append('avatar', file)
const resp = await fetch('/api/avatar', {
method: 'PUT',
body: formData,
})
if (!resp.ok) {
const data = await resp.json().catch(() => ({}))
throw new Error(data.error ?? 'Upload failed')
}
return resp.json() as Promise<{ url: string }>
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['session'] })
},
})
}
export function useDeleteAvatar() {
const qc = useQueryClient()
return useMutation({
mutationFn: async () => {
const resp = await fetch('/api/avatar', { method: 'DELETE' })
if (!resp.ok) throw new Error('Delete failed')
return resp.json()
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['session'] })
},
})
}

25
ui/src/api/client.ts Normal file
View File

@@ -0,0 +1,25 @@
const BASE = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`${res.status} ${res.statusText}: ${body}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}

32
ui/src/api/courier.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query'
import { api } from './client'
export interface CourierMessage {
id: string
recipient: string
subject: string
status: string
type: string
created_at: string
updated_at: string
body?: string
}
export function useCourierMessages(params?: { page_size?: number; status?: string; recipient?: string }) {
const search = new URLSearchParams()
if (params?.page_size) search.set('page_size', String(params.page_size))
if (params?.status) search.set('status', params.status)
if (params?.recipient) search.set('recipient', params.recipient)
return useQuery({
queryKey: ['courier', params],
queryFn: () => api.get<CourierMessage[]>(`/admin/courier/messages?${search}`),
})
}
export function useCourierMessage(id: string) {
return useQuery({
queryKey: ['courier', id],
queryFn: () => api.get<CourierMessage>(`/admin/courier/messages/${id}`),
enabled: !!id,
})
}

58
ui/src/api/flows.ts Normal file
View File

@@ -0,0 +1,58 @@
import { useQuery } from '@tanstack/react-query'
export interface FlowUI {
action: string
method: string
nodes: FlowNode[]
messages?: FlowMessage[]
}
export interface FlowNode {
type: 'input' | 'text' | 'img' | 'script' | 'a'
group: string
attributes: Record<string, unknown>
messages: FlowMessage[]
meta: {
label?: { id: number; text: string; type: string }
}
}
export interface FlowMessage {
id: number
text: string
type: 'error' | 'info' | 'success'
context?: Record<string, unknown>
}
export interface Flow {
id: string
type: string
ui: FlowUI
state?: string
request_url?: string
oauth2_login_challenge?: string
}
export function useFlow(type: string, flowId: string | null) {
return useQuery({
queryKey: ['flow', type, flowId],
queryFn: async () => {
const resp = await fetch(`/api/flow/${type}?flow=${flowId}`)
if (!resp.ok) throw new Error(`Failed to fetch flow: ${resp.status}`)
return resp.json() as Promise<Flow>
},
enabled: !!flowId,
})
}
export function useFlowError(errorId: string | null) {
return useQuery({
queryKey: ['flowError', errorId],
queryFn: async () => {
const resp = await fetch(`/api/flow/error?id=${errorId}`)
if (!resp.ok) throw new Error(`Failed to fetch error: ${resp.status}`)
return resp.json()
},
enabled: !!errorId,
})
}

75
ui/src/api/hydra.ts Normal file
View File

@@ -0,0 +1,75 @@
import { useQuery } from '@tanstack/react-query'
export interface ConsentRequest {
challenge: string
client: { client_id: string; client_name?: string; logo_uri?: string }
requested_scope: string[]
requested_access_token_audience: string[]
subject?: string
skip?: boolean
redirect_to?: string
auto?: boolean
}
export function useConsent(challenge: string | null) {
return useQuery({
queryKey: ['consent', challenge],
queryFn: async () => {
const resp = await fetch(`/api/hydra/consent?challenge=${challenge}`)
if (!resp.ok) throw new Error(`Failed to fetch consent: ${resp.status}`)
return resp.json() as Promise<ConsentRequest>
},
enabled: !!challenge,
})
}
export async function acceptConsent(
challenge: string,
grantScope: string[],
remember = false,
session?: Record<string, unknown>,
): Promise<{ redirect_to: string }> {
const resp = await fetch('/api/hydra/consent/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge, grantScope, remember, session }),
})
if (!resp.ok) throw new Error('Failed to accept consent')
return resp.json()
}
export async function rejectConsent(
challenge: string,
): Promise<{ redirect_to: string }> {
const resp = await fetch('/api/hydra/consent/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge }),
})
if (!resp.ok) throw new Error('Failed to reject consent')
return resp.json()
}
export function useLogoutRequest(challenge: string | null) {
return useQuery({
queryKey: ['logout', challenge],
queryFn: async () => {
const resp = await fetch(`/api/hydra/logout?challenge=${challenge}`)
if (!resp.ok) throw new Error(`Failed to fetch logout: ${resp.status}`)
return resp.json()
},
enabled: !!challenge,
})
}
export async function acceptLogout(
challenge: string,
): Promise<{ redirect_to: string }> {
const resp = await fetch('/api/hydra/logout/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge }),
})
if (!resp.ok) throw new Error('Failed to accept logout')
return resp.json()
}

100
ui/src/api/identities.ts Normal file
View File

@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
export interface Identity {
id: string
schema_id: string
state: 'active' | 'inactive'
traits: Record<string, unknown>
metadata_public?: Record<string, unknown>
metadata_admin?: Record<string, unknown>
credentials?: Record<string, { type: string; created_at: string }>
created_at: string
updated_at: string
}
export function useIdentities(params?: { page_size?: number; page_token?: string; credentials_identifier?: string }) {
const search = new URLSearchParams()
if (params?.page_size) search.set('page_size', String(params.page_size))
if (params?.page_token) search.set('page_token', params.page_token)
if (params?.credentials_identifier) search.set('credentials_identifier', params.credentials_identifier)
return useQuery({
queryKey: ['identities', params],
queryFn: () => api.get<Identity[]>(`/admin/identities?${search}`),
})
}
export function useIdentity(id: string) {
return useQuery({
queryKey: ['identities', id],
queryFn: () => api.get<Identity>(`/admin/identities/${id}`),
enabled: !!id,
})
}
export function useCreateIdentity() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { schema_id: string; traits: unknown; state?: string }) =>
api.post<Identity>('/admin/identities', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['identities'] }),
})
}
export function useUpdateIdentity(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Identity>) =>
api.put<Identity>(`/admin/identities/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['identities'] }),
})
}
export function useDeleteIdentity() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/admin/identities/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['identities'] }),
})
}
export function useGenerateRecoveryLink() {
return useMutation({
mutationFn: (body: { identity_id: string; expires_in?: string }) =>
api.post<{ recovery_link: string; expires_at: string }>('/admin/recovery/link', body),
})
}
export function useGenerateRecoveryCode() {
return useMutation({
mutationFn: (body: { identity_id: string; expires_in?: string }) =>
api.post<{ recovery_code: string; recovery_link: string; expires_at: string }>('/admin/recovery/code', body),
})
}
export interface Session {
id: string
identity_id?: string
active: boolean
expires_at: string
authenticated_at: string
authenticator_assurance_level: string
}
export function useIdentitySessions(identityId: string) {
return useQuery({
queryKey: ['identities', identityId, 'sessions'],
queryFn: () => api.get<Session[]>(`/admin/identities/${identityId}/sessions`),
enabled: !!identityId,
})
}
export function useDeleteAllIdentitySessions() {
const qc = useQueryClient()
return useMutation({
mutationFn: (identityId: string) =>
api.delete(`/admin/identities/${identityId}/sessions`),
onSuccess: (_, identityId) =>
qc.invalidateQueries({ queryKey: ['identities', identityId, 'sessions'] }),
})
}

23
ui/src/api/schemas.ts Normal file
View File

@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { api } from './client'
export interface SchemaListItem {
id: string
url?: string
}
export function useSchemas() {
return useQuery({
queryKey: ['schemas'],
queryFn: () => api.get<SchemaListItem[]>('/schemas'),
})
}
// Kratos GET /schemas/{id} returns the raw JSON schema directly
export function useSchema(id: string) {
return useQuery({
queryKey: ['schemas', id],
queryFn: () => api.get<Record<string, unknown>>(`/schemas/${encodeURIComponent(id)}`),
enabled: !!id,
})
}

6
ui/src/api/session.ts Normal file
View File

@@ -0,0 +1,6 @@
import { useSessionStore } from '../stores/session'
export function useSession() {
const { session, isAdmin, isLoading, error } = useSessionStore()
return { session, isAdmin, isLoading, error }
}

37
ui/src/api/sessions.ts Normal file
View File

@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
import type { Session } from './identities'
export function useSessions(params?: { page_size?: number; active?: boolean }) {
const search = new URLSearchParams()
if (params?.page_size) search.set('page_size', String(params.page_size))
if (params?.active !== undefined) search.set('active', String(params.active))
return useQuery({
queryKey: ['sessions', params],
queryFn: () => api.get<Session[]>(`/admin/sessions?${search}`),
})
}
export function useSession(id: string) {
return useQuery({
queryKey: ['sessions', id],
queryFn: () => api.get<Session>(`/admin/sessions/${id}`),
enabled: !!id,
})
}
export function useRevokeSession() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/admin/sessions/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }),
})
}
export function useExtendSession() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.patch(`/admin/sessions/${id}/extend`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }),
})
}

View File

@@ -0,0 +1,50 @@
interface AvatarProps {
identityId?: string
name?: string
picture?: string
size?: 'xsmall' | 'small' | 'medium' | 'large'
}
const sizes = {
xsmall: 24,
small: 32,
medium: 48,
large: 80,
}
export default function Avatar({ identityId, name, picture, size = 'medium' }: AvatarProps) {
const px = sizes[size]
const initial = (name?.[0] ?? '?').toUpperCase()
if (picture && identityId) {
return (
<img
src={`/api/avatar/${identityId}`}
alt={name ?? ''}
style={{
width: px,
height: px,
borderRadius: '50%',
objectFit: 'cover',
}}
/>
)
}
return (
<div style={{
width: px,
height: px,
borderRadius: '50%',
backgroundColor: 'var(--sunbeam--avatar-bg)',
color: 'var(--sunbeam--avatar-fg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: px * 0.4,
fontWeight: 600,
}}>
{initial}
</div>
)
}

View File

@@ -0,0 +1,108 @@
import { useRef, useState } from 'react'
import { Button } from '@gouvfr-lasuite/cunningham-react'
import { useUploadAvatar, useDeleteAvatar } from '../api/avatar'
interface AvatarUploadProps {
identityId: string
picture?: string
name?: string
onUploaded?: () => void
}
export default function AvatarUpload({ identityId, picture, name, onUploaded }: AvatarUploadProps) {
const fileRef = useRef<HTMLInputElement>(null)
const [preview, setPreview] = useState<string | null>(null)
const upload = useUploadAvatar()
const remove = useDeleteAvatar()
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
const currentSrc = preview ?? uploadedUrl ?? (picture ? `/api/avatar/${identityId}?t=${Date.now()}` : null)
const initial = (name?.[0] ?? '?').toUpperCase()
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => setPreview(reader.result as string)
reader.readAsDataURL(file)
try {
await upload.mutateAsync(file)
setUploadedUrl(`/api/avatar/${identityId}?t=${Date.now()}`)
onUploaded?.()
} catch {
setPreview(null)
setUploadedUrl(null)
}
}
const handleDelete = async () => {
await remove.mutateAsync()
setPreview(null)
onUploaded?.()
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div
onClick={() => fileRef.current?.click()}
style={{
width: 80,
height: 80,
borderRadius: '50%',
overflow: 'hidden',
cursor: 'pointer',
position: 'relative',
backgroundColor: 'var(--sunbeam--avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{currentSrc ? (
<img src={currentSrc} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<span style={{ color: 'var(--sunbeam--avatar-fg)', fontSize: '2rem', fontWeight: 600 }}>{initial}</span>
)}
<div style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: 0,
transition: 'opacity 0.2s',
color: '#fff',
fontSize: '0.7rem',
fontWeight: 500,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.opacity = '1' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.opacity = '0' }}
>
Change
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<Button color="neutral" size="small" onClick={() => fileRef.current?.click()}>
{upload.isPending ? 'Uploading...' : 'Upload photo'}
</Button>
{currentSrc && !preview && (
<Button color="error" size="small" onClick={handleDelete}>
{remove.isPending ? 'Removing...' : 'Remove'}
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react'
import { Button } from '@gouvfr-lasuite/cunningham-react'
interface ConfirmModalProps {
isOpen: boolean
title: string
message: string
confirmLabel?: string
confirmColor?: 'brand' | 'error'
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmModal({
isOpen,
title,
message,
confirmLabel = 'Confirm',
confirmColor = 'brand',
onConfirm,
onCancel,
}: ConfirmModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen && !dialog.open) {
dialog.showModal()
} else if (!isOpen && dialog.open) {
dialog.close()
}
}, [isOpen])
return (
<dialog
ref={dialogRef}
onClose={onCancel}
style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 12,
padding: '1.5rem',
maxWidth: 420,
width: '100%',
}}
>
<h3 style={{ margin: '0 0 0.75rem' }}>{title}</h3>
<p style={{ margin: '0 0 1.5rem', color: 'var(--sunbeam--text-secondary)' }}>{message}</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<Button color="neutral" onClick={onCancel}>
Cancel
</Button>
<Button color={confirmColor} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</dialog>
)
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react'
import { NavLink } from 'react-router-dom'
import { useSessionStore } from '../stores/session'
import { Button } from '@gouvfr-lasuite/cunningham-react'
interface HealthStatus {
alive: boolean
ready: boolean
}
const linkStyle: React.CSSProperties = {
display: 'block',
padding: '0.5rem 1rem',
textDecoration: 'none',
color: 'inherit',
borderRadius: 4,
}
const activeLinkStyle: React.CSSProperties = {
...linkStyle,
backgroundColor: 'var(--c--theme--colors--primary-100)',
fontWeight: 600,
color: 'var(--c--theme--colors--primary-700)',
}
export default function DashboardNav() {
const { isAdmin, needs2faSetup, logout } = useSessionStore()
const [health, setHealth] = useState<HealthStatus>({ alive: false, ready: false })
useEffect(() => {
const check = async () => {
const [alive, ready] = await Promise.all([
fetch('/api/health/alive').then((r) => r.ok).catch(() => false),
fetch('/api/health/ready').then((r) => r.ok).catch(() => false),
])
setHealth({ alive, ready })
}
check()
const interval = setInterval(check, 30_000)
return () => clearInterval(interval)
}, [])
return (
<nav style={{
width: 220,
borderRight: '1px solid var(--sunbeam--border)',
display: 'flex',
flexDirection: 'column',
padding: '1rem 0',
}}>
<div style={{ padding: '0 1rem', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0, fontSize: '1.125rem' }}>Sunbeam Studios</h2>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem', padding: '0 0.5rem' }}>
{!needs2faSetup && (
<NavLink
to="/profile"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Profile
</NavLink>
)}
<NavLink
to="/security"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Security
</NavLink>
{isAdmin && !needs2faSetup && (
<>
<div style={{
padding: '0.75rem 0.5rem 0.25rem',
fontSize: '0.7rem',
textTransform: 'uppercase',
color: 'var(--sunbeam--text-muted)',
fontWeight: 600,
letterSpacing: '0.05em',
}}>
Administration
</div>
<NavLink to="/identities" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Identities
</NavLink>
<NavLink to="/sessions" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Sessions
</NavLink>
<NavLink to="/courier" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Courier
</NavLink>
<NavLink to="/schemas" style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}>
Schemas
</NavLink>
</>
)}
</div>
<div style={{ padding: '0.5rem 1rem' }}>
<Button color="brand" size="small" fullWidth onClick={logout}>
Sign out
</Button>
</div>
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--sunbeam--border)', fontSize: '0.8rem', color: 'var(--sunbeam--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: health.alive ? 'var(--c--theme--colors--success-500)' : 'var(--c--theme--colors--danger-500)',
display: 'inline-block',
}} />
Alive
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: health.ready ? 'var(--c--theme--colors--success-500)' : 'var(--c--theme--colors--danger-500)',
display: 'inline-block',
}} />
Ready
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,103 @@
import type { FlowUI, FlowNode } from '../../api/flows'
import NodeInput from './NodeInput'
import NodeText from './NodeText'
import NodeImage from './NodeImage'
import NodeScript from './NodeScript'
import NodeAnchor from './NodeAnchor'
interface FlowFormProps {
ui: FlowUI
only?: string // render only this group
exclude?: string[] // exclude these groups
onSubmit?: (action: string, data: FormData) => void // intercept submission with fetch()
}
function renderNode(node: FlowNode, index: number) {
switch (node.type) {
case 'input':
return <NodeInput key={index} node={node} />
case 'text':
return <NodeText key={index} node={node} />
case 'img':
return <NodeImage key={index} node={node} />
case 'script':
return <NodeScript key={index} node={node} />
case 'a':
return <NodeAnchor key={index} node={node} />
default:
return null
}
}
const GROUP_ORDER = [
'default',
'password',
'oidc',
'code',
'webauthn',
'totp',
'lookup_secret',
]
export default function FlowForm({ ui, only, exclude, onSubmit }: FlowFormProps) {
const groups = new Map<string, FlowNode[]>()
for (const node of ui.nodes) {
const group = node.group
if (exclude?.includes(group)) continue
if (only && group !== only && group !== 'default') continue
if (!groups.has(group)) groups.set(group, [])
groups.get(group)!.push(node)
}
const sortedGroups = [...groups.entries()].sort(
([a], [b]) => GROUP_ORDER.indexOf(a) - GROUP_ORDER.indexOf(b),
)
const handleSubmit = onSubmit
? (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
onSubmit(ui.action, formData)
}
: undefined
return (
<form action={ui.action} method={ui.method} onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{ui.messages?.map((msg) => {
const isError = msg.type === 'error'
const isSuccess = msg.type === 'success'
return (
<div key={msg.id} style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: isError
? 'var(--c--theme--colors--danger-50)'
: isSuccess
? 'var(--c--theme--colors--success-50)'
: 'var(--c--theme--colors--info-50)',
border: `1px solid ${isError
? 'var(--c--theme--colors--danger-200)'
: isSuccess
? 'var(--c--theme--colors--success-200)'
: 'var(--c--theme--colors--info-200)'}`,
color: isError
? 'var(--c--theme--colors--danger-800)'
: isSuccess
? 'var(--c--theme--colors--success-800)'
: 'var(--c--theme--colors--info-800)',
fontSize: '0.875rem',
lineHeight: 1.5,
}}>
{msg.text}
</div>
)
})}
{sortedGroups.map(([group, nodes]) => (
<div key={group} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{nodes.map((node, i) => renderNode(node, i))}
</div>
))}
</form>
)
}

View File

@@ -0,0 +1,19 @@
import { Button } from '@gouvfr-lasuite/cunningham-react'
import type { FlowNode } from '../../api/flows'
interface Props {
node: FlowNode
}
export default function NodeAnchor({ node }: Props) {
const attrs = node.attributes as { href: string; title?: string; id?: string }
const label = node.meta?.label?.text ?? attrs.title ?? 'Link'
return (
<a href={attrs.href} style={{ textDecoration: 'none' }}>
<Button color="neutral" fullWidth>
{label}
</Button>
</a>
)
}

View File

@@ -0,0 +1,23 @@
import type { FlowNode } from '../../api/flows'
interface Props {
node: FlowNode
}
export default function NodeImage({ node }: Props) {
const attrs = node.attributes as { src: string; width?: number; height?: number }
const label = node.meta?.label?.text
return (
<div style={{ textAlign: 'center' }}>
{label && <div style={{ marginBottom: '0.5rem', fontWeight: 500 }}>{label}</div>}
<img
src={attrs.src}
width={attrs.width ?? 200}
height={attrs.height ?? 200}
alt={label ?? 'QR Code'}
style={{ maxWidth: '100%' }}
/>
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useRef, useEffect } from 'react'
import { Input, Button, Checkbox } from '@gouvfr-lasuite/cunningham-react'
import type { FlowNode } from '../../api/flows'
interface Props {
node: FlowNode
}
export default function NodeInput({ node }: Props) {
const attrs = node.attributes as {
name: string
type: string
value?: string
required?: boolean
disabled?: boolean
label?: string
onclick?: string
}
const label = node.meta?.label?.text ?? attrs.label ?? attrs.name
const errorMsg = node.messages.find((m) => m.type === 'error')
const infoMsg = node.messages.find((m) => m.type === 'info')
if (attrs.type === 'hidden') {
return <input type="hidden" name={attrs.name} value={attrs.value ?? ''} />
}
// WebAuthn and other interactive buttons with onclick handlers
// Use a native button so Kratos-injected scripts can attach via onclick attribute
if ((attrs.type === 'submit' || attrs.type === 'button') && attrs.onclick) {
return <NativeOnclickButton node={node} />
}
if (attrs.type === 'submit' || attrs.type === 'button') {
const isPrimary = node.group === 'password' || node.group === 'default'
return (
<>
{errorMsg && (
<div style={{ color: 'var(--c--theme--colors--danger-400)', fontSize: '0.8125rem', marginBottom: '0.25rem' }}>
{errorMsg.text}
</div>
)}
<Button
type="submit"
name={attrs.name}
value={attrs.value}
color={isPrimary ? 'brand' : 'neutral'}
disabled={attrs.disabled}
fullWidth
>
{label}
</Button>
</>
)
}
if (attrs.type === 'checkbox') {
return (
<Checkbox
label={label}
name={attrs.name}
defaultChecked={attrs.value === 'true'}
disabled={attrs.disabled}
/>
)
}
// Text, email, password, number, tel, etc.
return (
<Input
label={label}
name={attrs.name}
type={attrs.type === 'password' ? 'password' : attrs.type === 'email' ? 'email' : 'text'}
defaultValue={attrs.value ?? ''}
required={attrs.required}
disabled={attrs.disabled}
state={errorMsg ? 'error' : undefined}
text={errorMsg?.text ?? infoMsg?.text}
fullWidth
/>
)
}
// Renders a native <button> and sets the onclick attribute via the DOM
// so Kratos-injected scripts (WebAuthn) can trigger their ceremony handlers.
function NativeOnclickButton({ node }: Props) {
const ref = useRef<HTMLButtonElement>(null)
const attrs = node.attributes as {
name: string
type: string
value?: string
disabled?: boolean
onclick?: string
}
const label = node.meta?.label?.text ?? attrs.name
useEffect(() => {
if (ref.current && attrs.onclick) {
ref.current.setAttribute('onclick', attrs.onclick)
}
}, [attrs.onclick])
return (
<button
ref={ref}
type={attrs.type === 'button' ? 'button' : 'submit'}
name={attrs.name}
value={attrs.value}
disabled={attrs.disabled}
style={{
width: '100%',
padding: '0.625rem 1rem',
borderRadius: 4,
border: '1px solid var(--sunbeam--border)',
backgroundColor: 'var(--sunbeam--bg-muted)',
cursor: attrs.disabled ? 'not-allowed' : 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{label}
</button>
)
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react'
import type { FlowNode } from '../../api/flows'
interface Props {
node: FlowNode
}
export default function NodeScript({ node }: Props) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const attrs = node.attributes as {
src?: string
async?: boolean
type?: string
integrity?: string
crossorigin?: string
nonce?: string
}
if (!attrs.src || !ref.current) return
const script = document.createElement('script')
script.src = attrs.src
if (attrs.async) script.async = true
if (attrs.type) script.type = attrs.type
if (attrs.integrity) script.integrity = attrs.integrity
if (attrs.crossorigin) script.crossOrigin = attrs.crossorigin
if (attrs.nonce) script.nonce = attrs.nonce
ref.current.appendChild(script)
return () => {
script.remove()
}
}, [node])
return <div ref={ref} />
}

View File

@@ -0,0 +1,27 @@
import type { FlowNode } from '../../api/flows'
interface Props {
node: FlowNode
}
export default function NodeText({ node }: Props) {
const attrs = node.attributes as { text?: { text: string; id: number } }
const text = attrs.text?.text ?? ''
const label = node.meta?.label?.text
return (
<div style={{
padding: '1rem',
backgroundColor: 'var(--sunbeam--bg-muted)',
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
fontFamily: 'monospace',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{label && <div style={{ fontWeight: 600, marginBottom: '0.5rem', fontFamily: 'inherit' }}>{label}</div>}
{text}
</div>
)
}

View File

@@ -0,0 +1,44 @@
import Form from '@rjsf/core'
import validator from '@rjsf/validator-ajv8'
import type { RJSFSchema, UiSchema, ErrorSchema } from '@rjsf/utils'
import { CunninghamWidgets } from './widgets'
import { ObjectFieldTemplate, ArrayFieldTemplate } from './templates'
interface SchemaFormProps {
schema: RJSFSchema
uiSchema?: UiSchema
formData?: unknown
onSubmit: (data: unknown) => void
onError?: (errors: unknown) => void
extraErrors?: ErrorSchema<unknown>
disabled?: boolean
children?: React.ReactNode
}
export default function SchemaForm({
schema,
uiSchema,
formData,
onSubmit,
onError,
extraErrors,
disabled,
children,
}: SchemaFormProps) {
return (
<Form
schema={schema}
uiSchema={uiSchema}
formData={formData}
validator={validator}
widgets={CunninghamWidgets}
templates={{ ObjectFieldTemplate, ArrayFieldTemplate }}
extraErrors={extraErrors}
disabled={disabled}
onSubmit={({ formData }) => onSubmit(formData)}
onError={onError}
>
{children}
</Form>
)
}

View File

@@ -0,0 +1,32 @@
import type { ObjectFieldTemplateProps, ArrayFieldTemplateProps } from '@rjsf/utils'
import { Button } from '@gouvfr-lasuite/cunningham-react'
export function ObjectFieldTemplate({ title, properties, description }: ObjectFieldTemplateProps) {
return (
<fieldset style={{ border: '1px solid var(--c--theme--colors--greyscale-200)', borderRadius: '4px', padding: '1rem', marginBottom: '1rem' }}>
{title && <legend style={{ fontWeight: 600, padding: '0 0.5rem' }}>{title}</legend>}
{description && <p style={{ color: 'var(--c--theme--colors--greyscale-600)', marginBottom: '0.5rem' }}>{description}</p>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{properties.map((prop) => prop.content)}
</div>
</fieldset>
)
}
// In rjsf v6, items are pre-rendered ReactElements (each rendered by ArrayFieldItemTemplate,
// which handles its own remove/reorder buttons). ArrayFieldTemplate just provides the layout.
export function ArrayFieldTemplate({ title, items, canAdd, onAddClick }: ArrayFieldTemplateProps) {
return (
<div style={{ marginBottom: '1rem' }}>
{title && <h4 style={{ marginBottom: '0.5rem' }}>{title}</h4>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{items}
</div>
{canAdd && (
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={onAddClick}>+ Add item</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,86 @@
import type { WidgetProps } from '@rjsf/utils'
import { Input, Select, Checkbox } from '@gouvfr-lasuite/cunningham-react'
function TextWidget({ id, value, onChange, label, required, disabled, rawErrors }: WidgetProps) {
return (
<Input
label={label}
id={id}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
required={required}
disabled={disabled}
state={rawErrors?.length ? 'error' : 'default'}
text={rawErrors?.[0]}
/>
)
}
function EmailWidget({ id, value, onChange, label, required, disabled, rawErrors }: WidgetProps) {
return (
<Input
type="email"
label={label}
id={id}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
required={required}
disabled={disabled}
state={rawErrors?.length ? 'error' : 'default'}
text={rawErrors?.[0]}
/>
)
}
function SelectWidget({ value, onChange, label, disabled, options, rawErrors }: WidgetProps) {
const selectOptions = (options.enumOptions ?? []).map((o) => ({
label: String(o.label),
value: String(o.value),
}))
return (
<Select
label={label}
options={selectOptions}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
state={rawErrors?.length ? 'error' : 'default'}
text={rawErrors?.[0]}
/>
)
}
function CheckboxWidget({ value, onChange, label, disabled }: WidgetProps) {
return (
<Checkbox
label={label}
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
)
}
function NumberWidget({ id, value, onChange, label, required, disabled, rawErrors }: WidgetProps) {
return (
<Input
type="number"
label={label}
id={id}
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
required={required}
disabled={disabled}
state={rawErrors?.length ? 'error' : 'default'}
text={rawErrors?.[0]}
/>
)
}
export const CunninghamWidgets = {
TextWidget,
EmailWidget,
SelectWidget,
CheckboxWidget,
NumberWidget,
}

View File

@@ -0,0 +1,93 @@
import { useEffect, useRef, useCallback } from 'react'
const INTEGRATION_ORIGIN = window.location.origin.replace(/^https?:\/\/auth\./, 'https://integration.')
/**
* Waffle menu button that loads the La Gaufre v2 widget from the integration service.
* The widget is a Shadow DOM popup that shows links to all studio services.
*/
export default function WaffleButton() {
const btnRef = useRef<HTMLButtonElement>(null)
const initialized = useRef(false)
const toggle = useCallback(() => {
window._lasuite_widget = window._lasuite_widget || []
window._lasuite_widget.push(['lagaufre', 'toggle'])
}, [])
useEffect(() => {
if (initialized.current) return
initialized.current = true
// Load the lagaufre v2 widget script
const script = document.createElement('script')
script.src = `${INTEGRATION_ORIGIN}/api/v2/lagaufre.js`
script.onload = () => {
window._lasuite_widget = window._lasuite_widget || []
window._lasuite_widget.push(['lagaufre', 'init', {
api: `${INTEGRATION_ORIGIN}/api/v2/services.json`,
buttonElement: btnRef.current!,
label: 'Sunbeam Studios',
closeLabel: 'Close',
newWindowLabelSuffix: ' · new window',
}])
}
document.head.appendChild(script)
return () => {
window._lasuite_widget = window._lasuite_widget || []
window._lasuite_widget.push(['lagaufre', 'destroy'])
}
}, [])
return (
<button
ref={btnRef}
onClick={toggle}
aria-label="Apps"
aria-expanded="false"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 8,
border: '1px solid var(--sunbeam--border)',
backgroundColor: 'transparent',
cursor: 'pointer',
padding: 0,
color: 'var(--sunbeam--text-secondary)',
transition: 'background-color 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--c--theme--colors--greyscale-100)'
e.currentTarget.style.borderColor = 'var(--c--theme--colors--greyscale-300)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.borderColor = 'var(--sunbeam--border)'
}}
>
{/* 3x3 grid icon (waffle) */}
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<circle cx="3" cy="3" r="1.5" />
<circle cx="9" cy="3" r="1.5" />
<circle cx="15" cy="3" r="1.5" />
<circle cx="3" cy="9" r="1.5" />
<circle cx="9" cy="9" r="1.5" />
<circle cx="15" cy="9" r="1.5" />
<circle cx="3" cy="15" r="1.5" />
<circle cx="9" cy="15" r="1.5" />
<circle cx="15" cy="15" r="1.5" />
</svg>
</button>
)
}
// Global type augmentation for the widget API
declare global {
interface Window {
_lasuite_widget: unknown[]
}
}

View File

@@ -0,0 +1,37 @@
import { create } from 'zustand'
const defaultTheme = import.meta.env.VITE_CUNNINGHAM_THEME ?? 'default'
interface ThemeState {
theme: string
setTheme: (theme: string) => void
toggle: () => void
}
const getStoredTheme = (): string => {
try {
return localStorage.getItem('cunningham-theme') ?? defaultTheme
} catch {
return defaultTheme
}
}
export const useCunninghamTheme = create<ThemeState>((set, get) => ({
theme: getStoredTheme(),
setTheme: (theme: string) => {
localStorage.setItem('cunningham-theme', theme)
set({ theme })
},
toggle: () => {
const current = get().theme
const next = current.endsWith('-dark')
? current.replace('-dark', '-light')
: current === 'dark'
? 'default'
: current === 'default'
? 'dark'
: current.replace('-light', '-dark')
localStorage.setItem('cunningham-theme', next)
set({ theme: next })
},
}))

View File

@@ -0,0 +1,35 @@
import { Outlet } from 'react-router-dom'
export default function AuthLayout() {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--sunbeam--bg-page)',
padding: '2rem',
}}>
<div style={{ marginBottom: '1.5rem', textAlign: 'center' }}>
<h1 style={{
margin: 0,
fontSize: '1.5rem',
fontWeight: 600,
letterSpacing: '-0.01em',
color: 'var(--sunbeam--text-primary)',
}}>Sunbeam Studios</h1>
</div>
<div style={{
width: '100%',
maxWidth: 420,
backgroundColor: 'var(--sunbeam--bg-surface)',
borderRadius: 12,
padding: '2rem',
border: '1px solid var(--sunbeam--border)',
}}>
<Outlet />
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { useEffect } from 'react'
import { Outlet, useNavigate } from 'react-router-dom'
import { useSessionStore } from '../stores/session'
import DashboardNav from '../components/DashboardNav'
import WaffleButton from '../components/WaffleButton'
export default function DashboardLayout() {
const { session, isLoading, fetchSession } = useSessionStore()
const navigate = useNavigate()
useEffect(() => {
fetchSession()
}, [fetchSession])
useEffect(() => {
if (!isLoading && !session) {
navigate('/login', { replace: true })
}
}, [isLoading, session, navigate])
if (isLoading) {
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--sunbeam--text-secondary)',
}}>
Loading...
</div>
)
}
if (!session) return null
const identity = session.identity
const traits = (identity?.traits ?? {}) as Record<string, string>
const fullName = [traits.given_name, traits.family_name].filter(Boolean).join(' ') || traits.email || ''
return (
<div style={{ display: 'flex', height: '100vh' }}>
<DashboardNav />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<header style={{
padding: '0.75rem 1.5rem',
borderBottom: '1px solid var(--sunbeam--border)',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<WaffleButton />
<div style={{ width: 1, height: 24, backgroundColor: 'var(--sunbeam--border)' }} />
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>{fullName}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--sunbeam--text-secondary)' }}>{traits.email}</div>
</div>
{identity?.traits?.picture ? (
<img
src={`/api/avatar/${identity.id}`}
alt=""
style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<div style={{
width: 32,
height: 32,
borderRadius: '50%',
backgroundColor: 'var(--sunbeam--avatar-bg)',
color: 'var(--sunbeam--avatar-fg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.8rem',
fontWeight: 600,
}}>
{(fullName[0] ?? '?').toUpperCase()}
</div>
)}
</div>
</header>
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
<Outlet />
</main>
</div>
</div>
)
}

16
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@gouvfr-lasuite/cunningham-react/style'
import App from './App'
// Load theme AFTER Cunningham styles so our :root overrides win by source order
const themeLink = document.createElement('link')
themeLink.rel = 'stylesheet'
themeLink.href = window.location.origin.replace(/^https?:\/\/auth\./, 'https://integration.') + '/api/v2/theme.css'
document.head.appendChild(themeLink)
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button, Checkbox } from '@gouvfr-lasuite/cunningham-react'
import { useConsent, acceptConsent, rejectConsent } from '../../api/hydra'
const SCOPE_INFO: Record<string, { label: string; description: string }> = {
openid: { label: 'OpenID', description: 'Verify your identity' },
email: { label: 'Email', description: 'View your email address' },
profile: { label: 'Profile', description: 'View your name and profile info' },
offline_access: { label: 'Offline access', description: 'Stay signed in' },
}
export default function ConsentPage() {
const [params] = useSearchParams()
const challenge = params.get('consent_challenge')
const { data: consent, isLoading, error } = useConsent(challenge)
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(new Set())
const [remember, setRemember] = useState(true)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (consent?.redirect_to && consent?.auto) {
window.location.href = consent.redirect_to
}
if (consent?.requested_scope) {
setSelectedScopes(new Set(consent.requested_scope))
}
}, [consent])
if (!challenge) {
return <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Missing consent challenge.</p>
}
if (isLoading) return <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Loading...</p>
if (error) return <p style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</p>
if (!consent) return null
if (consent.redirect_to && consent.auto) {
return <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Redirecting...</p>
}
if (consent.skip) {
const doAccept = async () => {
const result = await acceptConsent(challenge, consent.requested_scope, true)
window.location.href = result.redirect_to
}
doAccept()
return <p style={{ color: 'var(--sunbeam--text-secondary)' }}>Redirecting...</p>
}
const handleAccept = async () => {
setSubmitting(true)
try {
const result = await acceptConsent(challenge, [...selectedScopes], remember)
window.location.href = result.redirect_to
} catch {
setSubmitting(false)
}
}
const handleReject = async () => {
setSubmitting(true)
try {
const result = await rejectConsent(challenge)
window.location.href = result.redirect_to
} catch {
setSubmitting(false)
}
}
const toggleScope = (scope: string) => {
const next = new Set(selectedScopes)
next.has(scope) ? next.delete(scope) : next.add(scope)
setSelectedScopes(next)
}
const clientName = consent.client?.client_name ?? consent.client?.client_id ?? 'An application'
return (
<div>
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: 'var(--c--theme--colors--primary-100)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: '0.75rem',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--c--theme--colors--primary-600)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h2 style={{ margin: '0 0 0.25rem', fontSize: '1.25rem', fontWeight: 600 }}>
Authorize {clientName}
</h2>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
This app would like permission to access your account.
</p>
</div>
<div style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
overflow: 'hidden',
marginBottom: '1rem',
}}>
{consent.requested_scope.map((scope, i) => {
const info = SCOPE_INFO[scope]
return (
<label
key={scope}
style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.75rem 1rem',
cursor: 'pointer',
borderTop: i > 0 ? '1px solid var(--sunbeam--border)' : 'none',
}}
>
<Checkbox
checked={selectedScopes.has(scope)}
onChange={() => toggleScope(scope)}
/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>
{info?.label ?? scope}
</div>
{info?.description && (
<div style={{ fontSize: '0.8125rem', color: 'var(--sunbeam--text-secondary)', marginTop: 1 }}>
{info.description}
</div>
)}
</div>
</label>
)
})}
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Checkbox
label="Remember this decision"
checked={remember}
onChange={() => setRemember(!remember)}
/>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Button color="brand" onClick={handleAccept} disabled={submitting} fullWidth>
Allow
</Button>
<Button color="neutral" onClick={handleReject} disabled={submitting} fullWidth>
Deny
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { useSearchParams } from 'react-router-dom'
import { useFlowError } from '../../api/flows'
export default function ErrorPage() {
const [params] = useSearchParams()
const errorId = params.get('id')
if (!errorId) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Error</h2>
<p>An unknown error occurred.</p>
<a href="/login">Back to sign in</a>
</div>
)
}
return <ErrorDetail errorId={errorId} />
}
function ErrorDetail({ errorId }: { errorId: string }) {
const { data, isLoading, error: fetchError } = useFlowError(errorId)
if (isLoading) return <div>Loading...</div>
if (fetchError) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Failed to load error details.</div>
const errorData = data?.error ?? data
const message = errorData?.message ?? errorData?.reason ?? 'An error occurred'
const status = errorData?.code ?? errorData?.status
return (
<div>
<h2 style={{ marginTop: 0 }}>Error{status ? ` ${status}` : ''}</h2>
<p>{message}</p>
{errorData?.debug && (
<pre>{errorData.debug}</pre>
)}
<a href="/login">Back to sign in</a>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useSearchParams } from 'react-router-dom'
import { useFlow } from '../../api/flows'
import FlowForm from '../../components/FlowNodes/FlowForm'
export default function LoginPage() {
const [params] = useSearchParams()
const flowId = params.get('flow')
const loginChallenge = params.get('login_challenge')
// If no flow ID, redirect to Kratos to create one
if (!flowId) {
const returnTo = params.get('return_to') ?? '/'
// Save return_to for the onboarding wizard to redirect back after setup
if (returnTo && returnTo !== '/') {
sessionStorage.setItem('onboarding_return_to', returnTo)
}
let url = `/kratos/self-service/login/browser?return_to=${encodeURIComponent(returnTo)}`
if (loginChallenge) url += `&login_challenge=${loginChallenge}`
window.location.href = url
return <div>Redirecting...</div>
}
return <LoginFlow flowId={flowId} />
}
function LoginFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('login', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading login flow: {String(error)}</div>
if (!flow) return null
// Detect dead-end: flow has messages but no actionable input nodes.
// This happens when Kratos wants aal2 but the user has no 2FA method set up.
const actionableNodes = flow.ui.nodes.filter(
n => n.type === 'input' &&
(n.attributes as Record<string, unknown>).type !== 'hidden' &&
n.group !== 'default'
)
if (actionableNodes.length === 0 && flow.ui.messages?.length) {
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem', textAlign: 'center' }}>
Additional setup required
</h2>
<FlowForm ui={flow.ui} />
<p style={{
color: 'var(--sunbeam--text-secondary)',
fontSize: '0.875rem',
textAlign: 'center',
margin: '1rem 0',
}}>
You need to set up two-factor authentication before you can sign in to services.
</p>
<div style={{ textAlign: 'center' }}>
<a href="/onboarding" style={{ fontWeight: 500 }}>Complete account setup</a>
</div>
</div>
)
}
// Inject remember=true so Kratos tells Hydra to persist the login session.
// Without this, every OAuth2 flow triggers a fresh login.
const uiWithRemember = {
...flow.ui,
nodes: [
...flow.ui.nodes,
{
type: 'input' as const,
group: 'default',
attributes: { name: 'remember', type: 'hidden', value: 'true', disabled: false, node_type: 'input' },
messages: [],
meta: {},
},
],
}
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem', textAlign: 'center' }}>Sign in</h2>
<FlowForm ui={uiWithRemember} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/recovery">Forgot password?</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@gouvfr-lasuite/cunningham-react'
import { useLogoutRequest, acceptLogout } from '../../api/hydra'
export default function LogoutPage() {
const [params] = useSearchParams()
const challenge = params.get('logout_challenge')
const { data: logoutReq, isLoading, error } = useLogoutRequest(challenge)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!challenge) {
fetch('/kratos/self-service/logout/browser', {
credentials: 'include',
redirect: 'manual',
}).then(async (resp) => {
if (resp.ok) {
const data = await resp.json()
if (data.logout_url) {
window.location.href = data.logout_url
return
}
}
window.location.href = '/login'
}).catch(() => {
window.location.href = '/login'
})
}
}, [challenge])
if (!challenge) return <div>Signing out...</div>
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
const handleAccept = async () => {
setSubmitting(true)
try {
const result = await acceptLogout(challenge)
window.location.href = result.redirect_to
} catch {
setSubmitting(false)
}
}
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1rem' }}>Sign out</h2>
<p>Do you want to sign out?</p>
{logoutReq?.subject && (
<p style={{ color: 'var(--sunbeam--text-secondary)' }}>
Signed in as: <strong>{logoutReq.subject}</strong>
</p>
)}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem' }}>
<Button color="brand" onClick={handleAccept} disabled={submitting} fullWidth>
Sign out
</Button>
<Button color="neutral" onClick={() => window.history.back()} disabled={submitting} fullWidth>
Cancel
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,311 @@
import { useState } from 'react'
import { Button, Input } from '@gouvfr-lasuite/cunningham-react'
import FlowForm from '../../components/FlowNodes/FlowForm'
import { useFlow } from '../../api/flows'
type Step = 'password' | 'totp' | 'done'
const STEP_LABELS: Record<Step, string> = {
password: 'Set password',
totp: 'Authenticator app',
done: 'Ready',
}
const STEPS: Step[] = ['password', 'totp', 'done']
export default function OnboardingWizard() {
const [step, setStep] = useState<Step>('password')
const stepIndex = STEPS.indexOf(step)
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '0.25rem', textAlign: 'center' }}>
Account setup
</h2>
<p style={{
textAlign: 'center',
color: 'var(--sunbeam--text-secondary)',
fontSize: '0.875rem',
marginBottom: '1.5rem',
}}>
Step {stepIndex + 1} of {STEPS.length}: {STEP_LABELS[step]}
</p>
<StepIndicator current={stepIndex} total={STEPS.length} />
{step === 'password' && (
<PasswordStep onComplete={() => setStep('totp')} />
)}
{step === 'totp' && (
<TotpStep onComplete={() => setStep('done')} />
)}
{step === 'done' && <DoneStep />}
</div>
)
}
function StepIndicator({ current, total }: { current: number; total: number }) {
return (
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
}}>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
style={{
flex: 1,
height: 4,
borderRadius: 2,
backgroundColor: i <= current
? 'var(--c--theme--colors--primary-500)'
: 'var(--sunbeam--border)',
transition: 'background-color 0.2s',
}}
/>
))}
</div>
)
}
function PasswordStep({ onComplete }: { onComplete: () => void }) {
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const mismatch = confirm.length > 0 && password !== confirm
const handleSubmit = async () => {
if (!password || password !== confirm) return
setSaving(true)
setMessage(null)
try {
const flowResp = await fetch('/kratos/self-service/settings/browser', {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!flowResp.ok) throw new Error('Failed to create settings flow')
const flow = await flowResp.json()
const csrfToken = flow.ui.nodes.find(
(n: { attributes: { name: string } }) => n.attributes.name === 'csrf_token'
)?.attributes?.value ?? ''
const submitResp = await fetch(flow.ui.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
credentials: 'include',
body: new URLSearchParams({
csrf_token: csrfToken,
method: 'password',
password,
}),
})
if (submitResp.ok || submitResp.status === 422) {
const result = await submitResp.json()
const errorMsg = result.ui?.messages?.find((m: { type: string }) => m.type === 'error')
if (errorMsg) {
setMessage({ type: 'error', text: errorMsg.text })
} else {
onComplete()
}
} else {
throw new Error('Failed to set password')
}
} catch (err) {
setMessage({ type: 'error', text: String(err) })
} finally {
setSaving(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Choose a strong password for your account.
</p>
{message && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: message.type === 'error'
? 'var(--c--theme--colors--danger-50)'
: 'var(--c--theme--colors--success-50)',
border: `1px solid ${message.type === 'error'
? 'var(--c--theme--colors--danger-200)'
: 'var(--c--theme--colors--success-200)'}`,
color: message.type === 'error'
? 'var(--c--theme--colors--danger-800)'
: 'var(--c--theme--colors--success-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Input
label="New password"
type="password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
fullWidth
/>
<Input
label="Confirm password"
type="password"
value={confirm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirm(e.target.value)}
state={mismatch ? 'error' : undefined}
text={mismatch ? 'Passwords do not match' : undefined}
fullWidth
/>
<Button color="brand" onClick={handleSubmit} disabled={saving || !password || mismatch} fullWidth>
{saving ? 'Setting password...' : 'Set password & continue'}
</Button>
</div>
)
}
function TotpStep({ onComplete }: { onComplete: () => void }) {
const [flowId, setFlowId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [started, setStarted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const startFlow = async () => {
setLoading(true)
try {
const resp = await fetch('/kratos/self-service/settings/browser', {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (resp.ok) {
const flow = await resp.json()
setFlowId(flow.id)
setStarted(true)
}
} catch {
// ignore
} finally {
setLoading(false)
}
}
const handleTotpSubmit = async (action: string, formData: FormData) => {
setSubmitting(true)
setError(null)
try {
// Submit button name/value isn't captured by FormData — add it explicitly
formData.set('method', 'totp')
const resp = await fetch(action, {
method: 'POST',
headers: { Accept: 'application/json' },
credentials: 'include',
body: new URLSearchParams(formData as unknown as Record<string, string>),
})
const result = await resp.json()
const errMsg = result.ui?.messages?.find((m: { type: string }) => m.type === 'error')
if (errMsg) {
setError(errMsg.text)
} else {
// Verify TOTP was actually set up
const sessionResp = await fetch('/api/auth/session')
if (sessionResp.ok) {
const data = await sessionResp.json()
if (!data.needs2faSetup) {
onComplete()
return
}
}
// If still needs setup, refresh the flow to show updated state
startFlow()
}
} catch (err) {
setError(String(err))
} finally {
setSubmitting(false)
}
}
if (!started) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Two-factor authentication is required. You'll need an authenticator
app like Google Authenticator, 1Password, or Authy.
</p>
<Button color="brand" onClick={startFlow} disabled={loading} fullWidth>
{loading ? 'Loading...' : 'Set up authenticator app'}
</Button>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Scan the QR code with your authenticator app, then enter the code below.
</p>
{error && (
<div style={{
padding: '0.75rem 1rem', borderRadius: 8,
backgroundColor: 'var(--c--theme--colors--danger-50)',
border: '1px solid var(--c--theme--colors--danger-200)',
color: 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>{error}</div>
)}
{flowId && <TotpFlow flowId={flowId} onSubmit={handleTotpSubmit} disabled={submitting} />}
</div>
)
}
function TotpFlow({ flowId, onSubmit, disabled }: {
flowId: string
onSubmit: (action: string, data: FormData) => void
disabled?: boolean
}) {
const { data: flow, isLoading, error } = useFlow('settings', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
if (!flow) return null
return (
<div style={{ opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? 'none' : 'auto' }}>
<FlowForm ui={flow.ui} only="totp" onSubmit={onSubmit} />
</div>
)
}
function DoneStep() {
const returnTo = sessionStorage.getItem('onboarding_return_to') || '/profile'
const handleContinue = () => {
sessionStorage.removeItem('onboarding_return_to')
window.location.replace(returnTo)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>&#10003;</div>
<p style={{ margin: 0, fontSize: '1rem', fontWeight: 500 }}>
You're all set!
</p>
<p style={{ margin: 0, color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>
Your account is secured with a password and two-factor authentication.
</p>
<Button color="brand" onClick={handleContinue} fullWidth>
Continue
</Button>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { useSearchParams } from 'react-router-dom'
import { useFlow } from '../../api/flows'
import FlowForm from '../../components/FlowNodes/FlowForm'
export default function RecoveryPage() {
const [params] = useSearchParams()
const flowId = params.get('flow')
if (!flowId) {
window.location.href = '/kratos/self-service/recovery/browser'
return <div>Redirecting...</div>
}
return <RecoveryFlow flowId={flowId} />
}
function RecoveryFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('recovery', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading recovery flow: {String(error)}</div>
if (!flow) return null
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem' }}>Account recovery</h2>
<FlowForm ui={flow.ui} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/login">Back to sign in</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { useSearchParams } from 'react-router-dom'
import { useFlow } from '../../api/flows'
import FlowForm from '../../components/FlowNodes/FlowForm'
export default function RegistrationPage() {
const [params] = useSearchParams()
const flowId = params.get('flow')
if (!flowId) {
const returnTo = params.get('return_to') ?? '/'
window.location.href = `/kratos/self-service/registration/browser?return_to=${encodeURIComponent(returnTo)}`
return <div>Redirecting...</div>
}
return <RegistrationFlow flowId={flowId} />
}
function RegistrationFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('registration', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading registration flow: {String(error)}</div>
if (!flow) return null
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem' }}>Create account</h2>
<FlowForm ui={flow.ui} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/login">Already have an account? Sign in</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { useSearchParams } from 'react-router-dom'
import { useFlow } from '../../api/flows'
import FlowForm from '../../components/FlowNodes/FlowForm'
export default function VerificationPage() {
const [params] = useSearchParams()
const flowId = params.get('flow')
if (!flowId) {
window.location.href = '/kratos/self-service/verification/browser'
return <div>Redirecting...</div>
}
return <VerificationFlow flowId={flowId} />
}
function VerificationFlow({ flowId }: { flowId: string }) {
const { data: flow, isLoading, error } = useFlow('verification', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error loading verification flow: {String(error)}</div>
if (!flow) return null
return (
<div>
<h2 style={{ marginTop: 0, marginBottom: '1.5rem' }}>Email verification</h2>
<FlowForm ui={flow.ui} />
<div style={{ marginTop: '1rem', textAlign: 'center', fontSize: '0.875rem' }}>
<a href="/login">Back to sign in</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,141 @@
import { useState } from 'react'
import { Button } from '@gouvfr-lasuite/cunningham-react'
import { useCourierMessages, useCourierMessage } from '../../api/courier'
const STATUS_COLORS: Record<string, string> = {
queued: 'var(--c--theme--colors--info-600)',
sent: 'var(--c--theme--colors--success-600)',
processing: 'var(--c--theme--colors--warning-600)',
abandoned: 'var(--c--theme--colors--danger-600)',
}
export default function CourierPage() {
const [statusFilter, setStatusFilter] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const { data: messages, isLoading, error } = useCourierMessages({
page_size: 50,
status: statusFilter || undefined,
})
const { data: detail } = useCourierMessage(selectedId ?? '')
if (isLoading) return <div>Loading courier messages...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
return (
<div>
<h1>Courier Messages</h1>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
{['', 'queued', 'sent', 'processing', 'abandoned'].map((s) => (
<Button
key={s}
size="small"
color={statusFilter === s ? 'brand' : 'neutral'}
onClick={() => setStatusFilter(s)}
>
{s || 'All'}
</Button>
))}
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<table>
<thead>
<tr>
<th>Recipient</th>
<th>Subject</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{(messages ?? []).map((msg) => (
<tr
key={msg.id}
style={{
cursor: 'pointer',
backgroundColor: selectedId === msg.id ? 'var(--c--theme--colors--primary-50)' : undefined,
}}
onClick={() => setSelectedId(msg.id)}
>
<td>{msg.recipient}</td>
<td>{msg.subject}</td>
<td>{msg.type}</td>
<td>
<span style={{ color: STATUS_COLORS[msg.status] ?? 'var(--sunbeam--text-secondary)' }}>
{msg.status}
</span>
</td>
<td>{new Date(msg.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
{(messages ?? []).length === 0 && (
<p style={{ color: 'var(--sunbeam--text-secondary)', textAlign: 'center', marginTop: '2rem' }}>No messages found.</p>
)}
</div>
{selectedId && detail && (
<div style={{
width: 400,
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
padding: '1rem',
}}>
<h3 style={{ marginTop: 0 }}>Message Detail</h3>
<table>
<tbody>
<tr>
<td style={{ fontWeight: 600 }}>ID</td>
<td><code>{detail.id}</code></td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Recipient</td>
<td>{detail.recipient}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Subject</td>
<td>{detail.subject}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Type</td>
<td>{detail.type}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Status</td>
<td>
<span style={{ color: STATUS_COLORS[detail.status] ?? 'var(--sunbeam--text-secondary)' }}>
{detail.status}
</span>
</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Created</td>
<td>{new Date(detail.created_at).toLocaleString()}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Updated</td>
<td>{new Date(detail.updated_at).toLocaleString()}</td>
</tr>
</tbody>
</table>
{detail.body && (
<>
<h4>Body</h4>
<pre style={{ maxHeight: 300 }}>{detail.body}</pre>
</>
)}
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={() => setSelectedId(null)}>Close</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Select } from '@gouvfr-lasuite/cunningham-react'
import { useSchemas, useSchema } from '../../api/schemas'
import { useCreateIdentity } from '../../api/identities'
import SchemaForm from '../../components/SchemaForm'
import type { RJSFSchema } from '@rjsf/utils'
export default function IdentityCreatePage() {
const navigate = useNavigate()
const { data: schemas, isLoading: schemasLoading } = useSchemas()
const createIdentity = useCreateIdentity()
const [selectedSchema, setSelectedSchema] = useState('')
const [state, setState] = useState<'active' | 'inactive'>('active')
const [error, setError] = useState<string | null>(null)
const { data: fetchedSchema } = useSchema(selectedSchema)
const traitsSchema = fetchedSchema as RJSFSchema | undefined
const schemaOptions = (schemas ?? []).map((s) => ({ label: s.id, value: s.id }))
const stateOptions = [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
]
const handleSubmit = async (traits: unknown) => {
setError(null)
try {
const result = await createIdentity.mutateAsync({
schema_id: selectedSchema,
traits,
state,
})
navigate(`/identities/${result.id}`)
} catch (e) {
setError(String(e))
}
}
if (schemasLoading) return <div>Loading schemas...</div>
return (
<div>
<h1>Create Identity</h1>
<div style={{ marginBottom: '1rem' }}>
<Select
label="Schema"
options={schemaOptions}
value={selectedSchema}
onChange={(e) => setSelectedSchema(e.target.value as string)}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<Select
label="State"
options={stateOptions}
value={state}
onChange={(e) => setState(e.target.value as 'active' | 'inactive')}
/>
</div>
{error && (
<div style={{ color: 'var(--c--theme--colors--danger-500)', marginBottom: '1rem' }}>{error}</div>
)}
{traitsSchema ? (
<SchemaForm
schema={traitsSchema}
onSubmit={handleSubmit}
disabled={createIdentity.isPending}
>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<Button type="submit" color="brand" disabled={createIdentity.isPending}>
{createIdentity.isPending ? 'Creating...' : 'Create Identity'}
</Button>
<Button type="button" color="neutral" onClick={() => navigate('/identities')}>Cancel</Button>
</div>
</SchemaForm>
) : (
selectedSchema && <div>Schema has no traits definition.</div>
)}
</div>
)
}

View File

@@ -0,0 +1,177 @@
import { useParams, Link, useNavigate } from 'react-router-dom'
import { Button } from '@gouvfr-lasuite/cunningham-react'
import { useIdentity, useIdentitySessions, useDeleteIdentity, useDeleteAllIdentitySessions, useGenerateRecoveryLink, useGenerateRecoveryCode } from '../../api/identities'
import { useState } from 'react'
import ConfirmModal from '../../components/ConfirmModal'
export default function IdentityDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: identity, isLoading, error } = useIdentity(id!)
const { data: sessions } = useIdentitySessions(id!)
const deleteIdentity = useDeleteIdentity()
const deleteAllSessions = useDeleteAllIdentitySessions()
const generateLink = useGenerateRecoveryLink()
const generateCode = useGenerateRecoveryCode()
const [confirmDelete, setConfirmDelete] = useState(false)
const [recoveryResult, setRecoveryResult] = useState<{ link?: string; code?: string } | null>(null)
if (isLoading) return <div>Loading identity...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
if (!identity) return <div>Identity not found.</div>
const handleDelete = async () => {
await deleteIdentity.mutateAsync(identity.id)
navigate('/identities')
}
const handleRecoveryLink = async () => {
const r = await generateLink.mutateAsync({ identity_id: identity.id })
setRecoveryResult({ link: r.recovery_link })
}
const handleRecoveryCode = async () => {
const r = await generateCode.mutateAsync({ identity_id: identity.id })
setRecoveryResult({ link: r.recovery_link, code: r.recovery_code })
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h1>Identity Detail</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Link to={`/identities/${identity.id}/edit`}>
<Button color="neutral" size="small">Edit</Button>
</Link>
<Button color="neutral" size="small" onClick={handleRecoveryLink}>Recovery Link</Button>
<Button color="neutral" size="small" onClick={handleRecoveryCode}>Recovery Code</Button>
<Button color="neutral" size="small" onClick={() => deleteAllSessions.mutateAsync(identity.id)}>Revoke All Sessions</Button>
<Button color="error" size="small" onClick={() => setConfirmDelete(true)}>Delete</Button>
</div>
</div>
{recoveryResult && (
<div style={{
background: 'var(--c--theme--colors--info-50)',
border: '1px solid var(--c--theme--colors--info-300)',
borderRadius: 8,
padding: '1rem',
marginBottom: '1rem',
}}>
<strong>Recovery:</strong>
{recoveryResult.code && <div>Code: <code>{recoveryResult.code}</code></div>}
{recoveryResult.link && <div>Link: <a href={recoveryResult.link} target="_blank" rel="noreferrer">{recoveryResult.link}</a></div>}
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={() => setRecoveryResult(null)}>Close</Button>
</div>
</div>
)}
<table style={{ marginBottom: '1.5rem' }}>
<tbody>
<tr>
<td style={{ fontWeight: 600 }}>ID</td>
<td><code>{identity.id}</code></td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Schema</td>
<td>{identity.schema_id}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>State</td>
<td>
<span style={{ color: identity.state === 'active' ? 'var(--c--theme--colors--success-600)' : 'var(--sunbeam--text-muted)' }}>
{identity.state}
</span>
</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Created</td>
<td>{new Date(identity.created_at).toLocaleString()}</td>
</tr>
<tr>
<td style={{ fontWeight: 600 }}>Updated</td>
<td>{new Date(identity.updated_at).toLocaleString()}</td>
</tr>
</tbody>
</table>
<h2>Traits</h2>
<pre>{JSON.stringify(identity.traits, null, 2)}</pre>
{identity.metadata_public && (
<>
<h2>Public Metadata</h2>
<pre>{JSON.stringify(identity.metadata_public, null, 2)}</pre>
</>
)}
{identity.metadata_admin && (
<>
<h2>Admin Metadata</h2>
<pre>{JSON.stringify(identity.metadata_admin, null, 2)}</pre>
</>
)}
{identity.credentials && (
<>
<h2>Credentials</h2>
<table>
<thead>
<tr>
<th>Type</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{Object.entries(identity.credentials).map(([key, cred]) => (
<tr key={key}>
<td>{cred.type}</td>
<td>{new Date(cred.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</>
)}
<h2>Sessions</h2>
{sessions && sessions.length > 0 ? (
<table>
<thead>
<tr>
<th>ID</th>
<th>Active</th>
<th>AAL</th>
<th>Authenticated</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.id}>
<td><code>{s.id.slice(0, 8)}...</code></td>
<td>{s.active ? 'Yes' : 'No'}</td>
<td>{s.authenticator_assurance_level}</td>
<td>{new Date(s.authenticated_at).toLocaleString()}</td>
<td>{new Date(s.expires_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ color: 'var(--sunbeam--text-secondary)' }}>No active sessions.</p>
)}
<ConfirmModal
isOpen={confirmDelete}
title="Delete Identity"
message="Are you sure you want to delete this identity? This cannot be undone."
confirmLabel="Delete"
confirmColor="error"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Button, Select } from '@gouvfr-lasuite/cunningham-react'
import { useIdentity, useUpdateIdentity } from '../../api/identities'
import { useSchema } from '../../api/schemas'
import SchemaForm from '../../components/SchemaForm'
import type { RJSFSchema } from '@rjsf/utils'
export default function IdentityEditPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: identity, isLoading } = useIdentity(id!)
const updateIdentity = useUpdateIdentity(id!)
const { data: fetchedSchema } = useSchema(identity?.schema_id ?? '')
const [error, setError] = useState<string | null>(null)
const [state, setState] = useState<string | null>(null)
if (isLoading) return <div>Loading identity...</div>
if (!identity) return <div>Identity not found.</div>
const traitsSchema = fetchedSchema as RJSFSchema | undefined
const currentState = state ?? identity.state
const stateOptions = [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
]
const handleSubmit = async (traits: unknown) => {
setError(null)
try {
await updateIdentity.mutateAsync({
schema_id: identity.schema_id,
traits: traits as Record<string, unknown>,
state: currentState as 'active' | 'inactive',
})
navigate(`/identities/${id}`)
} catch (e) {
setError(String(e))
}
}
return (
<div>
<h1>Edit Identity</h1>
<p style={{ color: 'var(--sunbeam--text-secondary)', marginBottom: '1rem' }}>
Schema: {identity.schema_id} | ID: <code>{identity.id}</code>
</p>
<div style={{ marginBottom: '1rem' }}>
<Select
label="State"
options={stateOptions}
value={currentState}
onChange={(e) => setState(e.target.value as string)}
/>
</div>
{error && (
<div style={{ color: 'var(--c--theme--colors--danger-500)', marginBottom: '1rem' }}>{error}</div>
)}
{traitsSchema ? (
<SchemaForm
schema={traitsSchema}
formData={identity.traits}
onSubmit={handleSubmit}
disabled={updateIdentity.isPending}
>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<Button type="submit" color="brand" disabled={updateIdentity.isPending}>
{updateIdentity.isPending ? 'Saving...' : 'Save Changes'}
</Button>
<Button type="button" color="neutral" onClick={() => navigate(`/identities/${id}`)}>Cancel</Button>
</div>
</SchemaForm>
) : (
<div>
<h2>Traits (raw JSON)</h2>
<pre>{JSON.stringify(identity.traits, null, 2)}</pre>
<p style={{ color: 'var(--sunbeam--text-secondary)' }}>Schema not available for form editing.</p>
<Button color="neutral" onClick={() => navigate(`/identities/${id}`)}>Back</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,171 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Button, Input, Checkbox } from '@gouvfr-lasuite/cunningham-react'
import { useIdentities, useDeleteIdentity, useGenerateRecoveryLink, useGenerateRecoveryCode, useDeleteAllIdentitySessions } from '../../api/identities'
import ConfirmModal from '../../components/ConfirmModal'
export default function IdentitiesPage() {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [recoveryResult, setRecoveryResult] = useState<{ link?: string; code?: string } | null>(null)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
const { data: identities, isLoading, error } = useIdentities(
debouncedSearch ? { credentials_identifier: debouncedSearch } : { page_size: 50 }
)
const deleteIdentity = useDeleteIdentity()
const generateLink = useGenerateRecoveryLink()
const generateCode = useGenerateRecoveryCode()
const deleteAllSessions = useDeleteAllIdentitySessions()
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value)
clearTimeout((window as unknown as { _searchTimer?: number })._searchTimer)
;(window as unknown as { _searchTimer?: number })._searchTimer = window.setTimeout(
() => setDebouncedSearch(e.target.value),
300
)
}
const toggleSelect = (id: string) => {
const next = new Set(selected)
next.has(id) ? next.delete(id) : next.add(id)
setSelected(next)
}
const handleBulkDelete = async () => {
for (const id of selected) await deleteIdentity.mutateAsync(id)
setSelected(new Set())
setConfirmBulkDelete(false)
}
const handleRecoveryLink = async (id: string) => {
const r = await generateLink.mutateAsync({ identity_id: id })
setRecoveryResult({ link: r.recovery_link })
}
const handleRecoveryCode = async (id: string) => {
const r = await generateCode.mutateAsync({ identity_id: id })
setRecoveryResult({ link: r.recovery_link, code: r.recovery_code })
}
if (isLoading) return <div>Loading identities...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h1>Identities</h1>
<Link to="/identities/create">
<Button color="brand" size="small">+ Create Identity</Button>
</Link>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<Input
label="Search by email"
value={search}
onChange={handleSearch}
/>
</div>
{selected.size > 0 && (
<Button color="error" size="small" onClick={() => setConfirmBulkDelete(true)}>
Delete {selected.size} selected
</Button>
)}
</div>
{recoveryResult && (
<div style={{
background: 'var(--c--theme--colors--info-50)',
border: '1px solid var(--c--theme--colors--info-300)',
borderRadius: 8,
padding: '1rem',
marginBottom: '1rem',
}}>
<strong>Recovery:</strong>
{recoveryResult.code && <div>Code: <code>{recoveryResult.code}</code></div>}
{recoveryResult.link && <div>Link: <a href={recoveryResult.link} target="_blank" rel="noreferrer">{recoveryResult.link}</a></div>}
<div style={{ marginTop: '0.5rem' }}>
<Button color="neutral" size="small" onClick={() => setRecoveryResult(null)}>Close</Button>
</div>
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '2rem' }}></th>
<th>Email</th>
<th>Schema</th>
<th>State</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{(identities ?? []).map((identity) => {
const email = (identity.traits as { email?: string })?.email ?? identity.id.slice(0, 8)
return (
<tr key={identity.id}>
<td>
<Checkbox
aria-label="Select identity"
checked={selected.has(identity.id)}
onChange={() => toggleSelect(identity.id)}
/>
</td>
<td>
<Link to={`/identities/${identity.id}`}>{email}</Link>
</td>
<td>{identity.schema_id}</td>
<td>
<span style={{ color: identity.state === 'active' ? 'var(--c--theme--colors--success-600)' : 'var(--sunbeam--text-muted)' }}>
{identity.state}
</span>
</td>
<td style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
<Link to={`/identities/${identity.id}`}>
<Button color="neutral" size="small">View</Button>
</Link>
<Link to={`/identities/${identity.id}/edit`}>
<Button color="neutral" size="small">Edit</Button>
</Link>
<Button color="neutral" size="small" onClick={() => handleRecoveryLink(identity.id)}>Link</Button>
<Button color="neutral" size="small" onClick={() => handleRecoveryCode(identity.id)}>Code</Button>
<Button color="neutral" size="small" onClick={() => deleteAllSessions.mutateAsync(identity.id)}>Revoke Sessions</Button>
<Button color="error" size="small" onClick={() => setConfirmDelete(identity.id)}>Delete</Button>
</td>
</tr>
)
})}
</tbody>
</table>
<ConfirmModal
isOpen={!!confirmDelete}
title="Delete Identity"
message="Are you sure you want to delete this identity? This cannot be undone."
confirmLabel="Delete"
confirmColor="error"
onConfirm={async () => {
if (confirmDelete) await deleteIdentity.mutateAsync(confirmDelete)
setConfirmDelete(null)
}}
onCancel={() => setConfirmDelete(null)}
/>
<ConfirmModal
isOpen={confirmBulkDelete}
title="Bulk Delete"
message={`Delete ${selected.size} identities? This cannot be undone.`}
confirmLabel="Delete All"
confirmColor="error"
onConfirm={handleBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { useState } from 'react'
import { Button } from '@gouvfr-lasuite/cunningham-react'
import { useSchemas, useSchema } from '../../api/schemas'
import SchemaForm from '../../components/SchemaForm'
import type { RJSFSchema } from '@rjsf/utils'
export default function SchemasPage() {
const { data: schemas, isLoading, error } = useSchemas()
const [selectedId, setSelectedId] = useState('')
const [viewMode, setViewMode] = useState<'json' | 'preview'>('json')
const { data: schema } = useSchema(selectedId)
if (isLoading) return <div>Loading schemas...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
const schemaTitle = (schema as Record<string, unknown>)?.title as string | undefined
return (
<div>
<h1 style={{ marginTop: 0 }}>Identity Schemas</h1>
<div style={{ display: 'flex', gap: '1.5rem' }}>
<div style={{ width: 200, flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{(schemas ?? []).map((s) => (
<Button
key={s.id}
size="small"
color={selectedId === s.id ? 'brand' : 'neutral'}
onClick={() => setSelectedId(s.id)}
fullWidth
>
{s.id}
</Button>
))}
</div>
{(schemas ?? []).length === 0 && (
<p style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem' }}>No schemas found.</p>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{schema ? (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.125rem' }}>{selectedId}</h2>
{schemaTitle && (
<div style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.8125rem', marginTop: 2 }}>
{schemaTitle}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
size="small"
color={viewMode === 'json' ? 'brand' : 'neutral'}
onClick={() => setViewMode('json')}
>
JSON
</Button>
<Button
size="small"
color={viewMode === 'preview' ? 'brand' : 'neutral'}
onClick={() => setViewMode('preview')}
>
Form Preview
</Button>
</div>
</div>
{viewMode === 'json' ? (
<pre style={{ maxHeight: 600 }}>
{JSON.stringify(schema, null, 2)}
</pre>
) : (
<div style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
padding: '1rem',
}}>
<p style={{ color: 'var(--sunbeam--text-secondary)', marginBottom: '1rem', fontSize: '0.875rem' }}>
Preview of the form generated from this schema. Submit is disabled.
</p>
<SchemaForm
schema={schema as RJSFSchema}
onSubmit={() => {}}
disabled
>
<Button type="submit" color="brand" size="small" disabled style={{ marginTop: '1rem' }}>
Submit (disabled)
</Button>
</SchemaForm>
</div>
)}
</>
) : (
<p style={{ color: 'var(--sunbeam--text-secondary)', marginTop: '2rem' }}>
Select a schema to view its details.
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react'
import { Button } from '@gouvfr-lasuite/cunningham-react'
import { useSessions, useRevokeSession, useExtendSession } from '../../api/sessions'
import ConfirmModal from '../../components/ConfirmModal'
export default function SessionsPage() {
const [activeFilter, setActiveFilter] = useState<boolean | undefined>(undefined)
const { data: sessions, isLoading, error } = useSessions({
page_size: 50,
active: activeFilter,
})
const revokeSession = useRevokeSession()
const extendSession = useExtendSession()
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null)
if (isLoading) return <div>Loading sessions...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
return (
<div>
<h1>Sessions</h1>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<Button
size="small"
color={activeFilter === undefined ? 'brand' : 'neutral'}
onClick={() => setActiveFilter(undefined)}
>
All
</Button>
<Button
size="small"
color={activeFilter === true ? 'brand' : 'neutral'}
onClick={() => setActiveFilter(true)}
>
Active
</Button>
<Button
size="small"
color={activeFilter === false ? 'brand' : 'neutral'}
onClick={() => setActiveFilter(false)}
>
Inactive
</Button>
</div>
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Identity</th>
<th>Active</th>
<th>AAL</th>
<th>Authenticated</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{(sessions ?? []).map((session) => (
<tr key={session.id}>
<td><code>{session.id.slice(0, 8)}...</code></td>
<td>
{session.identity_id ? (
<a href={`/identities/${session.identity_id}`}>
<code>{session.identity_id.slice(0, 8)}...</code>
</a>
) : (
'-'
)}
</td>
<td>
<span style={{ color: session.active ? 'var(--c--theme--colors--success-600)' : 'var(--sunbeam--text-muted)' }}>
{session.active ? 'Yes' : 'No'}
</span>
</td>
<td>{session.authenticator_assurance_level}</td>
<td>{new Date(session.authenticated_at).toLocaleString()}</td>
<td>{new Date(session.expires_at).toLocaleString()}</td>
<td style={{ display: 'flex', gap: '0.25rem' }}>
<Button
size="small"
color="neutral"
onClick={() => extendSession.mutate(session.id)}
disabled={!session.active}
>
Extend
</Button>
<Button
size="small"
color="error"
onClick={() => setConfirmRevoke(session.id)}
disabled={!session.active}
>
Revoke
</Button>
</td>
</tr>
))}
</tbody>
</table>
{(sessions ?? []).length === 0 && (
<p style={{ color: 'var(--sunbeam--text-secondary)', textAlign: 'center', marginTop: '2rem' }}>No sessions found.</p>
)}
<ConfirmModal
isOpen={!!confirmRevoke}
title="Revoke Session"
message="Are you sure you want to revoke this session? The user will be logged out."
confirmLabel="Revoke"
confirmColor="error"
onConfirm={async () => {
if (confirmRevoke) await revokeSession.mutateAsync(confirmRevoke)
setConfirmRevoke(null)
}}
onCancel={() => setConfirmRevoke(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react'
import { Input, Button } from '@gouvfr-lasuite/cunningham-react'
import { useSessionStore } from '../../stores/session'
import AvatarUpload from '../../components/AvatarUpload'
export default function ProfilePage() {
const { session, fetchSession } = useSessionStore()
if (!session) return <div>Loading...</div>
const identity = session.identity
const traits = (identity?.traits ?? {}) as Record<string, string>
const isEmployee = identity?.schema_id === 'employee'
return (
<div style={{ maxWidth: '640px' }}>
<h1 style={{ marginTop: 0 }}>Profile</h1>
<AvatarUpload
identityId={identity.id}
picture={traits.picture}
name={traits.given_name ?? traits.email}
onUploaded={fetchSession}
/>
<ProfileForm traits={traits} isEmployee={isEmployee} />
</div>
)
}
function ProfileForm({
traits,
isEmployee,
}: {
traits: Record<string, string>
isEmployee: boolean
}) {
const [values, setValues] = useState(traits)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
useEffect(() => {
setValues(traits)
}, [traits])
const handleSave = async () => {
setSaving(true)
setMessage(null)
try {
const flowResp = await fetch('/kratos/self-service/settings/browser', {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!flowResp.ok) throw new Error('Failed to create settings flow')
const flow = await flowResp.json()
const submitResp = await fetch(flow.ui.action, {
method: flow.ui.method,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'include',
body: new URLSearchParams({
'csrf_token': flow.ui.nodes.find(
(n: { attributes: { name: string } }) => n.attributes.name === 'csrf_token'
)?.attributes?.value ?? '',
method: 'profile',
'traits.email': traits.email ?? '',
'traits.given_name': values.given_name ?? '',
'traits.family_name': values.family_name ?? '',
'traits.nickname': values.nickname ?? '',
// Preserve picture trait so avatar isn't cleared on save
...(traits.picture ? { 'traits.picture': traits.picture } : {}),
...(isEmployee ? {
'traits.middle_name': values.middle_name ?? '',
'traits.phone_number': values.phone_number ?? '',
// Admin-managed fields: pass original values so Kratos doesn't clear them
'traits.job_title': traits.job_title ?? '',
'traits.department': traits.department ?? '',
'traits.office_location': traits.office_location ?? '',
'traits.employee_id': traits.employee_id ?? '',
'traits.hire_date': traits.hire_date ?? '',
'traits.manager': traits.manager ?? '',
} : {}),
}),
})
if (submitResp.ok || submitResp.status === 422) {
setMessage({ type: 'success', text: 'Profile updated successfully.' })
useSessionStore.getState().fetchSession()
} else {
throw new Error('Failed to update profile')
}
} catch (err) {
setMessage({ type: 'error', text: String(err) })
} finally {
setSaving(false)
}
}
const update = (key: string, value: string) => {
setValues((v) => ({ ...v, [key]: value }))
}
return (
<div style={{ marginTop: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{message && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: message.type === 'success' ? 'var(--c--theme--colors--success-50)' : 'var(--c--theme--colors--danger-50)',
border: `1px solid ${message.type === 'success' ? 'var(--c--theme--colors--success-200)' : 'var(--c--theme--colors--danger-200)'}`,
color: message.type === 'success' ? 'var(--c--theme--colors--success-800)' : 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Input
label="Email"
value={values.email ?? ''}
disabled
type="email"
fullWidth
/>
<div style={{ display: 'flex', gap: '1rem' }}>
<Input
label="First name"
value={values.given_name ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('given_name', e.target.value)}
fullWidth
/>
<Input
label="Last name"
value={values.family_name ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('family_name', e.target.value)}
fullWidth
/>
</div>
{isEmployee && (
<Input
label="Middle name"
value={values.middle_name ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('middle_name', e.target.value)}
fullWidth
/>
)}
<Input
label="Nickname"
value={values.nickname ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('nickname', e.target.value)}
fullWidth
/>
{isEmployee && (
<Input
label="Phone number"
value={values.phone_number ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => update('phone_number', e.target.value)}
fullWidth
/>
)}
<div>
<Button color="brand" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save profile'}
</Button>
</div>
{isEmployee && (
<>
<div style={{
marginTop: '1rem',
paddingTop: '1rem',
borderTop: '1px solid var(--sunbeam--border)',
}}>
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.9375rem' }}>Organization</h3>
<p style={{ margin: '0 0 1rem', color: 'var(--sunbeam--text-secondary)', fontSize: '0.8125rem' }}>
These fields are managed by an administrator.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '1rem' }}>
<Input label="Job title" value={traits.job_title ?? ''} disabled fullWidth />
<Input label="Department" value={traits.department ?? ''} disabled fullWidth />
</div>
<Input label="Office location" value={traits.office_location ?? ''} disabled fullWidth />
<div style={{ display: 'flex', gap: '1rem' }}>
<Input label="Employee ID" value={traits.employee_id ?? ''} disabled fullWidth />
<Input label="Hire date" value={traits.hire_date ?? ''} disabled fullWidth />
</div>
<Input label="Manager" value={traits.manager ?? ''} disabled fullWidth />
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,250 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button, Input } from '@gouvfr-lasuite/cunningham-react'
import { useFlow } from '../../api/flows'
import { useSessionStore } from '../../stores/session'
import FlowForm from '../../components/FlowNodes/FlowForm'
export default function SecurityPage() {
const [params] = useSearchParams()
const flowId = params.get('flow')
const { needs2faSetup } = useSessionStore()
// New users arriving from recovery flow: redirect to onboarding wizard
if (needs2faSetup) {
window.location.href = '/onboarding'
return <div>Redirecting to account setup...</div>
}
// If redirected back from Kratos with a flow param, show the result
if (flowId) {
return (
<div style={{ maxWidth: '640px' }}>
<h1 style={{ marginTop: 0 }}>Security</h1>
<SettingsFlow flowId={flowId} exclude={['profile']} />
</div>
)
}
return (
<div style={{ maxWidth: '640px' }}>
<h1 style={{ marginTop: 0 }}>Security</h1>
{needs2faSetup && (
<div style={{
padding: '1rem',
borderRadius: 8,
marginBottom: '1.5rem',
backgroundColor: 'var(--c--theme--colors--warning-100, #FFF3CD)',
border: '1px solid var(--c--theme--colors--warning-300, #FFDA6A)',
color: 'var(--c--theme--colors--warning-900, #664D03)',
fontSize: '0.875rem',
fontWeight: 500,
}}>
You must set up two-factor authentication before you can use this app.
Configure an authenticator app or security key below.
</div>
)}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.125rem' }}>Password</h2>
<PasswordSection />
</section>
<section>
<h2 style={{ fontSize: '1.125rem' }}>Two-Factor Authentication</h2>
<p style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
Add a second layer of security to your account using an authenticator app, security key, or backup codes.
</p>
<MfaSection method="totp" title="Authenticator App" description="Use an app like Google Authenticator or 1Password to generate one-time codes." />
<MfaSection method="webauthn" title="Security Key" description="Use a hardware security key, fingerprint, or Face ID." />
<MfaSection method="lookup_secret" title="Backup Codes" description="Generate one-time recovery codes in case you lose access to your other methods." />
</section>
</div>
)
}
function PasswordSection() {
const [expanded, setExpanded] = useState(false)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const mismatch = confirm.length > 0 && password !== confirm
const handleSubmit = async () => {
if (!password || password !== confirm) return
setSaving(true)
setMessage(null)
try {
const flowResp = await fetch('/kratos/self-service/settings/browser', {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!flowResp.ok) throw new Error('Failed to create settings flow')
const flow = await flowResp.json()
const csrfToken = flow.ui.nodes.find(
(n: { attributes: { name: string } }) => n.attributes.name === 'csrf_token'
)?.attributes?.value ?? ''
const submitResp = await fetch(flow.ui.action, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'include',
body: new URLSearchParams({
csrf_token: csrfToken,
method: 'password',
password,
}),
})
if (submitResp.ok || submitResp.status === 422) {
const result = await submitResp.json()
const errorMsg = result.ui?.messages?.find((m: { type: string }) => m.type === 'error')
if (errorMsg) {
setMessage({ type: 'error', text: errorMsg.text })
} else {
setMessage({ type: 'success', text: 'Password changed successfully.' })
setPassword('')
setConfirm('')
setExpanded(false)
}
} else {
throw new Error('Failed to change password')
}
} catch (err) {
setMessage({ type: 'error', text: String(err) })
} finally {
setSaving(false)
}
}
if (!expanded) {
return (
<>
{message && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
marginBottom: '0.75rem',
backgroundColor: message.type === 'success' ? 'var(--c--theme--colors--success-50)' : 'var(--c--theme--colors--danger-50)',
border: `1px solid ${message.type === 'success' ? 'var(--c--theme--colors--success-200)' : 'var(--c--theme--colors--danger-200)'}`,
color: message.type === 'success' ? 'var(--c--theme--colors--success-800)' : 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Button color="brand" onClick={() => setExpanded(true)}>
Change password
</Button>
</>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{message && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 8,
backgroundColor: message.type === 'success' ? 'var(--c--theme--colors--success-50)' : 'var(--c--theme--colors--danger-50)',
border: `1px solid ${message.type === 'success' ? 'var(--c--theme--colors--success-200)' : 'var(--c--theme--colors--danger-200)'}`,
color: message.type === 'success' ? 'var(--c--theme--colors--success-800)' : 'var(--c--theme--colors--danger-800)',
fontSize: '0.875rem',
}}>
{message.text}
</div>
)}
<Input
label="New password"
type="password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
fullWidth
/>
<Input
label="Confirm password"
type="password"
value={confirm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirm(e.target.value)}
state={mismatch ? 'error' : undefined}
text={mismatch ? 'Passwords do not match' : undefined}
fullWidth
/>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button color="brand" onClick={handleSubmit} disabled={saving || !password || mismatch}>
{saving ? 'Saving...' : 'Update password'}
</Button>
<Button color="neutral" onClick={() => { setExpanded(false); setPassword(''); setConfirm('') }}>
Cancel
</Button>
</div>
</div>
)
}
function MfaSection({ method, title, description }: { method: string; title: string; description: string }) {
const [flowId, setFlowId] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState(false)
const startFlow = async () => {
setLoading(true)
try {
const resp = await fetch('/kratos/self-service/settings/browser', {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (resp.ok) {
const flow = await resp.json()
setFlowId(flow.id)
setExpanded(true)
}
} catch {
// ignore
} finally {
setLoading(false)
}
}
return (
<div style={{
border: '1px solid var(--sunbeam--border)',
borderRadius: 8,
padding: '1rem',
marginBottom: '0.75rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9375rem' }}>{title}</div>
<div style={{ color: 'var(--sunbeam--text-secondary)', fontSize: '0.8125rem', marginTop: 2 }}>
{description}
</div>
</div>
{!expanded && (
<Button color="brand" size="small" onClick={startFlow} disabled={loading}>
{loading ? 'Loading...' : 'Configure'}
</Button>
)}
</div>
{expanded && flowId && (
<div style={{ marginTop: '1rem', borderTop: '1px solid var(--sunbeam--border)', paddingTop: '1rem' }}>
<SettingsFlow flowId={flowId} only={method} />
</div>
)}
</div>
)
}
function SettingsFlow({ flowId, only, exclude }: { flowId: string; only?: string; exclude?: string[] }) {
const { data: flow, isLoading, error } = useFlow('settings', flowId)
if (isLoading) return <div>Loading...</div>
if (error) return <div style={{ color: 'var(--c--theme--colors--danger-500)' }}>Error: {String(error)}</div>
if (!flow) return null
return <FlowForm ui={flow.ui} only={only} exclude={exclude} />
}

105
ui/src/stores/session.ts Normal file
View File

@@ -0,0 +1,105 @@
import { create } from 'zustand'
interface Identity {
id: string
schema_id: string
traits: Record<string, unknown>
state: string
metadata_public?: Record<string, unknown>
}
interface Session {
id: string
active: boolean
identity: Identity
authenticated_at: string
expires_at: string
}
interface SessionState {
session: Session | null
isAdmin: boolean
needs2faSetup: boolean
isLoading: boolean
error: string | null
fetchSession: () => Promise<void>
logout: () => Promise<void>
}
export const useSessionStore = create<SessionState>((set) => ({
session: null,
isAdmin: false,
needs2faSetup: false,
isLoading: true,
error: null,
fetchSession: async () => {
set({ isLoading: true, error: null })
try {
const resp = await fetch('/api/auth/session')
if (resp.status === 403) {
const data = await resp.json().catch(() => null)
// AAL2 required — redirect to TOTP/WebAuthn step-up
if (data?.needsAal2) {
const returnTo = encodeURIComponent(window.location.href)
window.location.href = `/kratos/self-service/login/browser?aal=aal2&return_to=${returnTo}`
return
}
// 2FA not set up — redirect to onboarding wizard
if (data?.needs2faSetup) {
set({ session: null, isAdmin: false, needs2faSetup: true, isLoading: false })
if (window.location.pathname !== '/onboarding' && window.location.pathname !== '/security') {
window.location.href = '/onboarding'
}
return
}
}
if (!resp.ok) {
set({ session: null, isAdmin: false, needs2faSetup: false, isLoading: false })
return
}
const data = await resp.json()
if (data.needs2faSetup && window.location.pathname !== '/onboarding' && window.location.pathname !== '/security') {
window.location.href = '/onboarding'
return
}
set({
session: data.session,
isAdmin: data.isAdmin,
needs2faSetup: data.needs2faSetup ?? false,
isLoading: false,
})
} catch (err) {
set({
session: null,
isAdmin: false,
needs2faSetup: false,
isLoading: false,
error: String(err),
})
}
},
logout: async () => {
try {
// Revoke ALL sessions for this identity (force logout everywhere)
await fetch('/api/auth/sessions', { method: 'DELETE', credentials: 'include' })
// Then perform the browser logout flow to clear the current cookie
const resp = await fetch('/kratos/self-service/logout/browser', {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (resp.ok) {
const data = await resp.json()
if (data.logout_url) {
window.location.href = data.logout_url
return
}
}
} catch {
// Fall through
}
window.location.href = '/login'
},
}))

22
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts", "cunningham.ts"]
}

14
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
build: {
outDir: 'dist',
},
})