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:
10
ui/cunningham.ts
Normal file
10
ui/cunningham.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
themes: {
|
||||
default: {},
|
||||
dark: {},
|
||||
"dsfr-light": {},
|
||||
"dsfr-dark": {},
|
||||
"anct-light": {},
|
||||
"anct-dark": {},
|
||||
},
|
||||
};
|
||||
16
ui/index.html
Normal file
16
ui/index.html
Normal 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
30
ui/package.json
Normal 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
13
ui/public/favicon.svg
Normal 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
72
ui/src/App.tsx
Normal 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
41
ui/src/api/avatar.ts
Normal 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
25
ui/src/api/client.ts
Normal 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
32
ui/src/api/courier.ts
Normal 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
58
ui/src/api/flows.ts
Normal 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
75
ui/src/api/hydra.ts
Normal 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
100
ui/src/api/identities.ts
Normal 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
23
ui/src/api/schemas.ts
Normal 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
6
ui/src/api/session.ts
Normal 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
37
ui/src/api/sessions.ts
Normal 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'] }),
|
||||
})
|
||||
}
|
||||
50
ui/src/components/Avatar.tsx
Normal file
50
ui/src/components/Avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
ui/src/components/AvatarUpload.tsx
Normal file
108
ui/src/components/AvatarUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
ui/src/components/ConfirmModal.tsx
Normal file
60
ui/src/components/ConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
ui/src/components/DashboardNav.tsx
Normal file
126
ui/src/components/DashboardNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
ui/src/components/FlowNodes/FlowForm.tsx
Normal file
103
ui/src/components/FlowNodes/FlowForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
ui/src/components/FlowNodes/NodeAnchor.tsx
Normal file
19
ui/src/components/FlowNodes/NodeAnchor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
ui/src/components/FlowNodes/NodeImage.tsx
Normal file
23
ui/src/components/FlowNodes/NodeImage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
ui/src/components/FlowNodes/NodeInput.tsx
Normal file
124
ui/src/components/FlowNodes/NodeInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
ui/src/components/FlowNodes/NodeScript.tsx
Normal file
39
ui/src/components/FlowNodes/NodeScript.tsx
Normal 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} />
|
||||
}
|
||||
27
ui/src/components/FlowNodes/NodeText.tsx
Normal file
27
ui/src/components/FlowNodes/NodeText.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
ui/src/components/SchemaForm/index.tsx
Normal file
44
ui/src/components/SchemaForm/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
ui/src/components/SchemaForm/templates.tsx
Normal file
32
ui/src/components/SchemaForm/templates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
ui/src/components/SchemaForm/widgets.tsx
Normal file
86
ui/src/components/SchemaForm/widgets.tsx
Normal 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,
|
||||
}
|
||||
93
ui/src/components/WaffleButton.tsx
Normal file
93
ui/src/components/WaffleButton.tsx
Normal 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[]
|
||||
}
|
||||
}
|
||||
37
ui/src/cunningham/useCunninghamTheme.tsx
Normal file
37
ui/src/cunningham/useCunninghamTheme.tsx
Normal 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 })
|
||||
},
|
||||
}))
|
||||
35
ui/src/layouts/AuthLayout.tsx
Normal file
35
ui/src/layouts/AuthLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
ui/src/layouts/DashboardLayout.tsx
Normal file
89
ui/src/layouts/DashboardLayout.tsx
Normal 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
16
ui/src/main.tsx
Normal 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>,
|
||||
)
|
||||
156
ui/src/pages/auth/ConsentPage.tsx
Normal file
156
ui/src/pages/auth/ConsentPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
ui/src/pages/auth/ErrorPage.tsx
Normal file
41
ui/src/pages/auth/ErrorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
ui/src/pages/auth/LoginPage.tsx
Normal file
88
ui/src/pages/auth/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
ui/src/pages/auth/LogoutPage.tsx
Normal file
65
ui/src/pages/auth/LogoutPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
311
ui/src/pages/auth/OnboardingWizard.tsx
Normal file
311
ui/src/pages/auth/OnboardingWizard.tsx
Normal 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' }}>✓</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>
|
||||
)
|
||||
}
|
||||
33
ui/src/pages/auth/RecoveryPage.tsx
Normal file
33
ui/src/pages/auth/RecoveryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
ui/src/pages/auth/RegistrationPage.tsx
Normal file
34
ui/src/pages/auth/RegistrationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
ui/src/pages/auth/VerificationPage.tsx
Normal file
33
ui/src/pages/auth/VerificationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
ui/src/pages/courier/index.tsx
Normal file
141
ui/src/pages/courier/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
ui/src/pages/identities/create.tsx
Normal file
86
ui/src/pages/identities/create.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
ui/src/pages/identities/detail.tsx
Normal file
177
ui/src/pages/identities/detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
ui/src/pages/identities/edit.tsx
Normal file
87
ui/src/pages/identities/edit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
ui/src/pages/identities/index.tsx
Normal file
171
ui/src/pages/identities/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
ui/src/pages/schemas/index.tsx
Normal file
106
ui/src/pages/schemas/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
ui/src/pages/sessions/index.tsx
Normal file
121
ui/src/pages/sessions/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
ui/src/pages/settings/profile.tsx
Normal file
200
ui/src/pages/settings/profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
250
ui/src/pages/settings/security.tsx
Normal file
250
ui/src/pages/settings/security.tsx
Normal 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
105
ui/src/stores/session.ts
Normal 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
22
ui/tsconfig.json
Normal 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
14
ui/vite.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user