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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user