feat: custom Logto sign-in UI with Cameleer branding
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 50s

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:
hsiegeln
2026-04-06 11:43:22 +02:00
parent b1c2832245
commit df220bc5f3
17 changed files with 2352 additions and 1 deletions

View File

@@ -9,6 +9,15 @@ RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_T
COPY ui/ .
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)
FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /build
@@ -25,6 +34,9 @@ FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S cameleer && adduser -S cameleer -G cameleer
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
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -64,11 +64,14 @@ services:
postgres:
condition: service_healthy
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
volumes:
- signinui:/etc/logto/custom-ui
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
ADMIN_ENDPOINT: http://${PUBLIC_HOST:-localhost}:3002
TRUST_PROXY_HEADER: 1
CUSTOM_UI_PATH: /etc/logto/custom-ui
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))\""]
interval: 5s
@@ -130,6 +133,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- jardata:/data/jars
- bootstrapdata:/data/bootstrap:ro
- signinui:/data/sign-in-ui
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
@@ -238,3 +242,4 @@ volumes:
certs:
jardata:
bootstrapdata:
signinui:

View 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 "$@"

View 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

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
View File

@@ -0,0 +1 @@
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/

13
ui/sign-in/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

23
ui/sign-in/package.json Normal file
View 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
View File

@@ -0,0 +1,5 @@
import { SignInPage } from './SignInPage';
export function App() {
return <SignInPage />;
}

View 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%;
}

View 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>
);
}

View 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
View 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
View 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
View 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"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
},
});