feat: custom Logto sign-in UI with Cameleer branding
Replace Logto's default sign-in page with a custom React SPA that matches the cameleer3-server login page using @cameleer/design-system. - New Vite+React app at ui/sign-in/ with Experience API integration - 4-step auth flow: init → verify password → identify → submit - Design-system components: Card, Input, Button, FormField, Alert - Same witty random subtitles as cameleer3-server LoginPage - Dockerfile: add sign-in-frontend build stage, copy dist to image - docker-compose: CUSTOM_UI_PATH on Logto, shared signinui volume - SaaS entrypoint copies sign-in dist to shared volume on startup - Add .gitattributes for LF line endings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
Dockerfile
14
Dockerfile
@@ -9,6 +9,15 @@ RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_T
|
|||||||
COPY ui/ .
|
COPY ui/ .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Sign-in UI: custom Logto sign-in experience
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS sign-in-frontend
|
||||||
|
ARG REGISTRY_TOKEN
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY ui/sign-in/package.json ui/sign-in/package-lock.json ui/sign-in/.npmrc ./
|
||||||
|
RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && npm ci
|
||||||
|
COPY ui/sign-in/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Maven build: runs natively on build host (no QEMU emulation)
|
# Maven build: runs natively on build host (no QEMU emulation)
|
||||||
FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-alpine AS build
|
FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-alpine AS build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
@@ -25,6 +34,9 @@ FROM eclipse-temurin:21-jre-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN addgroup -S cameleer && adduser -S cameleer -G cameleer
|
RUN addgroup -S cameleer && adduser -S cameleer -G cameleer
|
||||||
COPY --from=build /build/target/*.jar app.jar
|
COPY --from=build /build/target/*.jar app.jar
|
||||||
|
COPY --from=sign-in-frontend /ui/dist/ /app/sign-in-dist/
|
||||||
|
COPY docker/saas-entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
USER cameleer
|
USER cameleer
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|||||||
@@ -64,11 +64,14 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
|
||||||
|
volumes:
|
||||||
|
- signinui:/etc/logto/custom-ui
|
||||||
environment:
|
environment:
|
||||||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
|
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
|
||||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||||
ADMIN_ENDPOINT: http://${PUBLIC_HOST:-localhost}:3002
|
ADMIN_ENDPOINT: http://${PUBLIC_HOST:-localhost}:3002
|
||||||
TRUST_PROXY_HEADER: 1
|
TRUST_PROXY_HEADER: 1
|
||||||
|
CUSTOM_UI_PATH: /etc/logto/custom-ui
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -130,6 +133,7 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- jardata:/data/jars
|
- jardata:/data/jars
|
||||||
- bootstrapdata:/data/bootstrap:ro
|
- bootstrapdata:/data/bootstrap:ro
|
||||||
|
- signinui:/data/sign-in-ui
|
||||||
environment:
|
environment:
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||||||
@@ -238,3 +242,4 @@ volumes:
|
|||||||
certs:
|
certs:
|
||||||
jardata:
|
jardata:
|
||||||
bootstrapdata:
|
bootstrapdata:
|
||||||
|
signinui:
|
||||||
|
|||||||
7
docker/saas-entrypoint.sh
Normal file
7
docker/saas-entrypoint.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Copy sign-in UI dist to shared volume for Logto's CUSTOM_UI_PATH
|
||||||
|
if [ -d /app/sign-in-dist ] && [ -d /data/sign-in-ui ]; then
|
||||||
|
cp -r /app/sign-in-dist/* /data/sign-in-ui/
|
||||||
|
echo "[saas] Copied sign-in UI to shared volume"
|
||||||
|
fi
|
||||||
|
exec java -jar /app/app.jar "$@"
|
||||||
103
docs/superpowers/specs/2026-04-06-custom-sign-in-ui-design.md
Normal file
103
docs/superpowers/specs/2026-04-06-custom-sign-in-ui-design.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Custom Logto Sign-In UI
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Logto's default sign-in page uses Logto branding. While we configured colors and logos via `PATCH /api/sign-in-exp`, control over layout, typography, and components is limited. The sign-in experience is visually inconsistent with the cameleer3-server login page.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace Logto's sign-in UI with a custom React SPA that visually matches the cameleer3-server login page, using `@cameleer/design-system` components for consistency across all deployment models.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**MVP**: Username/password sign-in only.
|
||||||
|
**Later**: Sign-up, forgot password, social login, MFA.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Source
|
||||||
|
|
||||||
|
Separate Vite+React app at `ui/sign-in/`. Own `package.json`, shares `@cameleer/design-system` v0.1.31.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
Added as a Docker build stage in the SaaS `Dockerfile`. The dist is copied into the SaaS image at `/app/sign-in-dist/`.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
SaaS entrypoint (`docker/saas-entrypoint.sh`) copies the built sign-in dist to a shared Docker volume (`signinui`). Logto reads from that volume via `CUSTOM_UI_PATH=/etc/logto/custom-ui`.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User visits /platform/ → SaaS redirects to Logto OIDC
|
||||||
|
→ Logto sets interaction cookie, serves custom sign-in UI
|
||||||
|
→ User enters credentials → Experience API 4-step flow
|
||||||
|
→ Logto redirects back to /platform/callback with auth code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Experience API
|
||||||
|
|
||||||
|
The custom UI communicates with Logto via the Experience API (stateful, cookie-based):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. PUT /api/experience → 204 (init SignIn)
|
||||||
|
2. POST /api/experience/verification/password → 200 { verificationId }
|
||||||
|
3. POST /api/experience/identification → 204 (confirm identity)
|
||||||
|
4. POST /api/experience/submit → 200 { redirectTo }
|
||||||
|
```
|
||||||
|
|
||||||
|
The interaction cookie is set by `/oidc/auth` before the user lands on the sign-in page. All API calls use `credentials: "same-origin"`.
|
||||||
|
|
||||||
|
## Visual Design
|
||||||
|
|
||||||
|
Matches cameleer3-server LoginPage exactly:
|
||||||
|
- Centered `Card` (400px max-width, 32px padding)
|
||||||
|
- Logo: favicon.svg + "cameleer3" text (24px bold)
|
||||||
|
- Random witty subtitle (13px muted)
|
||||||
|
- `FormField` + `Input` for username and password
|
||||||
|
- Amber `Button` (primary variant, full-width)
|
||||||
|
- `Alert` for errors
|
||||||
|
- Background: `var(--bg-base)` (light beige)
|
||||||
|
- Dark mode support via design-system tokens
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `ui/sign-in/package.json` | React 19 + @cameleer/design-system + Vite 6 |
|
||||||
|
| `ui/sign-in/.npmrc` | Gitea npm registry for @cameleer scope |
|
||||||
|
| `ui/sign-in/tsconfig.json` | TypeScript config |
|
||||||
|
| `ui/sign-in/vite.config.ts` | Vite build config |
|
||||||
|
| `ui/sign-in/index.html` | Entry HTML |
|
||||||
|
| `ui/sign-in/src/main.tsx` | React mount + design-system CSS import |
|
||||||
|
| `ui/sign-in/src/App.tsx` | App shell |
|
||||||
|
| `ui/sign-in/src/SignInPage.tsx` | Login form with Experience API integration |
|
||||||
|
| `ui/sign-in/src/SignInPage.module.css` | CSS Modules (matches server LoginPage) |
|
||||||
|
| `ui/sign-in/src/experience-api.ts` | Typed Experience API client |
|
||||||
|
| `docker/saas-entrypoint.sh` | Copies sign-in dist to shared volume |
|
||||||
|
| `.gitattributes` | LF line endings |
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `Dockerfile` | Add `sign-in-frontend` build stage, copy dist to runtime image |
|
||||||
|
| `docker-compose.yml` | Add `CUSTOM_UI_PATH` to Logto, `signinui` shared volume |
|
||||||
|
|
||||||
|
## Docker Timing
|
||||||
|
|
||||||
|
Logto starts before SaaS (dependency chain: postgres → logto → logto-bootstrap → cameleer-saas). The `CUSTOM_UI_PATH` volume is initially empty. This is acceptable because:
|
||||||
|
1. Nobody hits the sign-in page until redirected from `/platform/`
|
||||||
|
2. By that time, SaaS has started and copied the dist files
|
||||||
|
3. Logto serves static files at request time, not startup time
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
- Add React Router for multiple pages (sign-up, forgot password)
|
||||||
|
- Fetch `GET /api/.well-known/sign-in-exp` for dynamic sign-in method detection
|
||||||
|
- Social login buttons via `POST /api/experience/verification/social`
|
||||||
|
- MFA via `POST /api/experience/verification/totp`
|
||||||
|
- Consent page via `GET /api/interaction/consent`
|
||||||
3
ui/public/favicon.svg
Normal file
3
ui/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.3 KiB |
1
ui/sign-in/.npmrc
Normal file
1
ui/sign-in/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
13
ui/sign-in/index.html
Normal file
13
ui/sign-in/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sign in — cameleer3</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/platform/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1903
ui/sign-in/package-lock.json
generated
Normal file
1903
ui/sign-in/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
ui/sign-in/package.json
Normal file
23
ui/sign-in/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "cameleer-sign-in",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cameleer/design-system": "0.1.31",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "^6.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
ui/sign-in/src/App.tsx
Normal file
5
ui/sign-in/src/App.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SignInPage } from './SignInPage';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <SignInPage />;
|
||||||
|
}
|
||||||
58
ui/sign-in/src/SignInPage.module.css
Normal file
58
ui/sign-in/src/SignInPage.module.css
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
109
ui/sign-in/src/SignInPage.tsx
Normal file
109
ui/sign-in/src/SignInPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { type FormEvent, useMemo, useState } from 'react';
|
||||||
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
|
import { signIn } from './experience-api';
|
||||||
|
import styles from './SignInPage.module.css';
|
||||||
|
|
||||||
|
const SUBTITLES = [
|
||||||
|
"Prove you're not a mirage",
|
||||||
|
"Only authorized cameleers beyond this dune",
|
||||||
|
"Halt, traveler — state your business",
|
||||||
|
"The caravan doesn't move without credentials",
|
||||||
|
"No hitchhikers on this caravan",
|
||||||
|
"This oasis requires a password",
|
||||||
|
"Camels remember faces. We use passwords.",
|
||||||
|
"You shall not pass... without logging in",
|
||||||
|
"The desert is vast. Your session has expired.",
|
||||||
|
"Another day, another dune to authenticate",
|
||||||
|
"Papers, please. The caravan master is watching.",
|
||||||
|
"Trust, but verify — ancient cameleer proverb",
|
||||||
|
"Even the Silk Road had checkpoints",
|
||||||
|
"Your camel is parked outside. Now identify yourself.",
|
||||||
|
"One does not simply walk into the dashboard",
|
||||||
|
"The sands shift, but your password shouldn't",
|
||||||
|
"Unauthorized access? In this economy?",
|
||||||
|
"Welcome back, weary traveler",
|
||||||
|
"The dashboard awaits on the other side of this dune",
|
||||||
|
"Keep calm and authenticate",
|
||||||
|
"Who goes there? Friend or rogue exchange?",
|
||||||
|
"Access denied looks the same in every desert",
|
||||||
|
"May your routes be green and your tokens valid",
|
||||||
|
"Forgot your password? That's between you and the dunes.",
|
||||||
|
"No ticket, no caravan",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SignInPage() {
|
||||||
|
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const redirectTo = await signIn(username, password);
|
||||||
|
window.location.replace(redirectTo);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.loginForm}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<img src="/platform/favicon.svg" alt="" className={styles.logoImg} />
|
||||||
|
cameleer3
|
||||||
|
</div>
|
||||||
|
<p className={styles.subtitle}>{subtitle}</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<Alert variant="error">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||||
|
<FormField label="Username" htmlFor="login-username">
|
||||||
|
<Input
|
||||||
|
id="login-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter your username"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Password" htmlFor="login-password">
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
ui/sign-in/src/experience-api.ts
Normal file
63
ui/sign-in/src/experience-api.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const BASE = '/api/experience';
|
||||||
|
|
||||||
|
async function request(method: string, path: string, body?: unknown): Promise<Response> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initInteraction(): Promise<void> {
|
||||||
|
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/password', {
|
||||||
|
identifier: { type: 'username', value: username },
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('Invalid username or password');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Authentication failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function identifyUser(verificationId: string): Promise<void> {
|
||||||
|
const res = await request('POST', '/identification', { verificationId });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Identification failed (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitInteraction(): Promise<string> {
|
||||||
|
const res = await request('POST', '/submit');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Submit failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.redirectTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(username: string, password: string): Promise<string> {
|
||||||
|
await initInteraction();
|
||||||
|
const verificationId = await verifyPassword(username, password);
|
||||||
|
await identifyUser(verificationId);
|
||||||
|
return submitInteraction();
|
||||||
|
}
|
||||||
10
ui/sign-in/src/main.tsx
Normal file
10
ui/sign-in/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import '@cameleer/design-system/style.css';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
6
ui/sign-in/src/vite-env.d.ts
vendored
Normal file
6
ui/sign-in/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: { readonly [key: string]: string };
|
||||||
|
export default classes;
|
||||||
|
}
|
||||||
21
ui/sign-in/tsconfig.json
Normal file
21
ui/sign-in/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
9
ui/sign-in/vite.config.ts
Normal file
9
ui/sign-in/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user