feat(schemas): add new API schema definitions for invitation, deployment, and issue management

- Add AcceptInvitationParams and AcceptInvitationRequest schemas
- Add AddDeployKeyParams, AddDomainParams, AddGpgKeyParams, AddMemberParams, AddReplyParams, AddRepoMemberParams, and AddSshKeyParams schemas
- Add ApiEmptyResponse and ApiErrorResponse schemas
- Add ApiResponse schemas for BranchMergeCheck, BranchProtectionRule, CaptchaResponse, ContextMe, CreateInvitationResponse, EmailResponse, Enable2FAResponse, Get2FAStatusResponse
- Add ApiResponse schemas for Issue, IssueAssignee, IssueComment, IssueEvent, IssueLabel, IssueLabelRelation, IssueMilestone, IssuePrRelation, IssueReaction, IssueRepoRelation, IssueSubscriber, and IssueTemplate
- Add ApiResponse schemas for Option_BranchProtectionRule, PrAssignee, PrCheckRun, PrCommit, PrEvent, PrFile, and PrLabel
This commit is contained in:
zhenyi
2026-06-07 21:20:51 +08:00
commit defde2bca9
706 changed files with 72213 additions and 0 deletions
+579
View File
@@ -0,0 +1,579 @@
/* ─────────────────────────────────────────────────────────────────
* Authks — Design System Tokens (Light + Dark)
* ───────────────────────────────────────────────────────────────── */
/* Override #root during auth pages */
.auth-page #root {
width: 100%;
max-width: none;
border: none;
text-align: left;
padding: 0;
margin: 0;
}
.auth-root {
/* Reset conflicting styles from index.css */
margin: 0;
padding: 0;
border: none;
/* Surfaces */
--bg: #ffffff;
--surface: #f5f5f7;
--surface-warm: #fbfbfd;
/* Foreground ramp */
--fg: #1d1d1f;
--fg-2: #424245;
--muted: #6e6e73;
--meta: #86868b;
/* Borders */
--border: #d2d2d7;
--border-soft: #e8e8ed;
/* Accent */
--accent: #0071e3;
--accent-on: #ffffff;
--accent-hover: #0077ed;
--accent-active: #0066cc;
/* Semantic */
--success: #16a34a;
--warn: #eab308;
--danger: #dc2626;
/* Typography */
--font-display: "SF Pro Display", "SF Pro Icons", "Helvetica Neue", "Inter", system-ui, -apple-system, sans-serif;
--font-body: "SF Pro Text", "SF Pro Icons", "Helvetica Neue", "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, Monaco, Consolas, monospace;
/* Radius */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 980px;
/* Motion */
--motion-fast: 150ms;
--motion-base: 220ms;
--ease-standard: cubic-bezier(0.28, 0, 0.22, 1);
/* Focus */
--focus-ring: 0 0 0 4px color-mix(in oklab, var(--accent), transparent 65%);
/* Elevation */
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 12px 32px rgba(0, 0, 0, 0.08);
font-family: var(--font-body);
font-size: 17px;
line-height: 1.47;
color: var(--fg);
background: var(--surface);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
min-height: 100dvh;
}
@media (prefers-color-scheme: dark) {
.auth-root {
--bg: #1c1c1e;
--surface: #2c2c2e;
--surface-warm: #232325;
--fg: #f5f5f7;
--fg-2: #d1d1d6;
--muted: #98989d;
--meta: #86868b;
--border: #48484a;
--border-soft: #38383a;
--accent: #0a84ff;
--accent-on: #ffffff;
--accent-hover: #409cff;
--accent-active: #0071e3;
--success: #30d158;
--warn: #ffd60a;
--danger: #ff453a;
--elev-raised: 0 12px 32px rgba(0, 0, 0, 0.4);
}
}
.auth-root *,
.auth-root ::before,
.auth-root ::after {
box-sizing: border-box;
}
.auth-root h1,
.auth-root h2,
.auth-root h3,
.auth-root h4,
.auth-root h5,
.auth-root h6 {
font-family: var(--font-display);
font-weight: 600;
font-size: inherit;
letter-spacing: -0.015em;
margin: 0;
}
.auth-root a {
color: var(--accent);
text-decoration: none;
transition: color var(--motion-fast) var(--ease-standard);
}
.auth-root a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* ─── Form field base ─────────────────────────────────────────────── */
.auth-root .field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.auth-root .field label {
font-size: 14px;
font-weight: 600;
color: var(--fg);
}
.auth-root .field .field-optional {
font-size: 12px;
font-weight: 400;
color: var(--meta);
margin-left: 4px;
}
.auth-root .field input {
width: 100%;
min-width: 0;
height: 48px;
padding: 0 16px;
font-family: var(--font-body);
font-size: 17px;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.auth-root .field input::placeholder {
color: var(--meta);
}
.auth-root .field input:hover {
border-color: var(--meta);
}
.auth-root .field input:focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.auth-root .field input.input-error {
border-color: var(--danger);
}
.auth-root .field input.input-error:focus-visible {
box-shadow: 0 0 0 4px color-mix(in oklab, var(--danger), transparent 65%);
}
.auth-root .field input:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--surface);
}
/* ─── Password group ──────────────────────────────────────────────── */
.auth-root .password-group {
position: relative;
display: flex;
align-items: center;
}
.auth-root .password-group input {
width: 100%;
height: 48px;
padding: 0 44px 0 16px;
font-family: var(--font-body);
font-size: 17px;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.auth-root .password-group input::placeholder {
color: var(--meta);
}
.auth-root .password-group input:hover {
border-color: var(--meta);
}
.auth-root .password-group input:focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.auth-root .password-group input.input-error {
border-color: var(--danger);
}
.auth-root .password-group input.input-error:focus-visible {
box-shadow: 0 0 0 4px color-mix(in oklab, var(--danger), transparent 65%);
}
.auth-root .password-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--surface);
}
.auth-root .eye-btn {
position: absolute;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--meta);
transition: background var(--motion-fast) var(--ease-standard);
}
.auth-root .eye-btn:hover {
background: color-mix(in oklab, var(--fg), transparent 94%);
}
.auth-root .eye-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* ─── Error banner ─────────────────────────────────────────────────── */
.auth-root .error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
font-size: 14px;
color: var(--danger);
background: color-mix(in oklab, var(--danger), transparent 92%);
border: 1px solid color-mix(in oklab, var(--danger), transparent 80%);
border-radius: 12px;
line-height: 1.4;
}
.auth-root .error-banner[role="alert"]:focus {
outline: none;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--danger), transparent 65%);
}
/* ─── Buttons ────────────────────────────────────────────────────── */
.auth-root .btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
height: 48px;
padding: 0 24px;
font-family: var(--font-body);
font-size: 17px;
font-weight: 600;
color: var(--accent-on);
background: var(--accent);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--motion-fast) var(--ease-standard),
transform var(--motion-fast) var(--ease-standard);
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.auth-root .btn-primary:hover {
background: var(--accent-hover);
}
.auth-root .btn-primary:active {
background: var(--accent-active);
transform: scale(0.98);
}
.auth-root .btn-primary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.auth-root .btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-root .btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
height: 48px;
padding: 0 20px;
font-family: var(--font-body);
font-size: 17px;
font-weight: 600;
color: var(--accent);
background: transparent;
border: 1px solid var(--border-soft);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--motion-fast) var(--ease-standard),
border-color var(--motion-fast) var(--ease-standard);
}
.auth-root .btn-secondary:hover {
background: color-mix(in oklab, var(--accent), transparent 92%);
border-color: color-mix(in oklab, var(--accent), transparent 70%);
}
.auth-root .btn-secondary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.auth-root .btn-block {
width: 100%;
}
/* ─── Form layout primitives ──────────────────────────────────────── */
.auth-root .form-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.auth-root .form-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
width: 100%;
}
@media (max-width: 600px) {
.auth-root .form-row {
grid-template-columns: 1fr;
gap: 14px;
}
}
.auth-root .form-header {
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
color: var(--fg);
letter-spacing: -0.015em;
text-align: center;
margin: 0 0 2px;
}
.auth-root .form-desc {
font-family: var(--font-body);
font-size: 15px;
color: var(--muted);
line-height: 1.47;
text-align: center;
margin: 0;
}
.auth-root .form-hint {
font-family: var(--font-body);
font-size: 13px;
color: var(--meta);
line-height: 1.4;
margin: -8px 0 0;
}
/* ─── Divider ──────────────────────────────────────────────────────── */
.auth-root .auth-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 2px 0;
}
.auth-root .auth-divider::before,
.auth-root .auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-soft);
}
.auth-root .auth-divider span {
font-family: var(--font-body);
font-size: 13px;
color: var(--meta);
flex-shrink: 0;
}
/* ─── Spinner ──────────────────────────────────────────────────────── */
.auth-root .spinner-sm {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
/* ─── Back link ────────────────────────────────────────────────────── */
.auth-root .back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--muted);
text-decoration: none;
transition: color var(--motion-fast) var(--ease-standard);
}
.auth-root .back-link:hover {
color: var(--fg);
text-decoration: none;
}
.auth-root .forgot-link-row {
display: flex;
justify-content: flex-end;
margin-top: -8px;
}
/* ─── Status icon ──────────────────────────────────────────────────── */
.auth-root .status-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.auth-root .status-icon--accent {
background: color-mix(in oklab, var(--accent), transparent 90%);
}
.auth-root .status-icon--success {
background: color-mix(in oklab, var(--success), transparent 90%);
}
.auth-root .status-icon--warn {
background: color-mix(in oklab, var(--warn), transparent 90%);
}
/* ─── Strength meter ──────────────────────────────────────────────── */
.auth-root .strength-meter {
display: flex;
align-items: center;
gap: 10px;
margin-top: -2px;
}
.auth-root .strength-meter__track {
flex: 1;
height: 4px;
background: var(--border-soft);
border-radius: 2px;
overflow: hidden;
}
.auth-root .strength-meter__fill {
height: 100%;
border-radius: 2px;
transition: width var(--motion-base) var(--ease-standard),
background var(--motion-base) var(--ease-standard);
}
.auth-root .strength-meter__label {
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
min-width: 32px;
text-align: right;
}
/* ─── Captcha ───────────────────────────────────────────────────── */
.auth-root .captcha-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.auth-root .captcha-group label {
font-size: 14px;
font-weight: 600;
color: var(--fg);
}
.auth-root .captcha-row {
display: flex;
gap: 12px;
align-items: center;
}
.auth-root .captcha-row img {
height: 48px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-soft);
cursor: pointer;
flex-shrink: 0;
}
.auth-root .captcha-row input {
flex: 1;
min-width: 0;
height: 48px;
padding: 0 16px;
font-family: var(--font-body);
font-size: 17px;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.auth-root .captcha-row input::placeholder {
color: var(--meta);
}
.auth-root .captcha-row input:focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
/* ─── PIN code input ─────────────────────────────────────────────── */
.auth-root .pin-code-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.auth-root .pin-code-input {
width: 100%;
min-width: 0;
height: 52px;
padding: 0;
font-family: var(--font-mono);
font-size: 24px;
font-weight: 600;
color: var(--fg);
text-align: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard),
transform var(--motion-fast) var(--ease-standard);
}
.auth-root .pin-code-input:focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
transform: translateY(-1px);
}
.auth-root .pin-code-input:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--surface);
}
/* ─── Animations ─────────────────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+61
View File
@@ -0,0 +1,61 @@
import { onMount, onCleanup, type JSX } from 'solid-js';
import '@/app/auth/auth.css';
interface AuthLayoutProps {
children: JSX.Element;
eyebrow?: string;
maxWidth?: number;
}
export default function AuthLayout({ children, eyebrow, maxWidth }: AuthLayoutProps) {
onMount(() => document.documentElement.classList.add('auth-page'));
onCleanup(() => document.documentElement.classList.remove('auth-page'));
return (
<div class="auth-root" style={s.wrapper}>
<div style={s.backdrop} />
<div style={{ ...s.container, 'max-width': `${maxWidth ?? 460}px` }}>
<header style={s.header}>
<p style={s.brandName}>AppKS</p>
</header>
{eyebrow && <p style={s.eyebrow}>{eyebrow}</p>}
<div style={s.card}>{children}</div>
<footer style={s.footer}>
&copy; {new Date().getFullYear()} AppKS. All rights reserved.
</footer>
</div>
</div>
);
}
const s: Record<string, JSX.CSSProperties> = {
wrapper: {
position: 'relative', display: 'flex', 'justify-content': 'center',
'min-height': '100dvh', padding: '48px 16px',
},
backdrop: {
position: 'fixed', inset: '0', background: 'var(--surface)', 'z-index': '0',
},
container: {
position: 'relative', 'z-index': '1', width: '100%',
display: 'flex', 'flex-direction': 'column', 'align-items': 'center',
gap: '24px', animation: 'fade-in 0.4s var(--ease-standard) both', margin: 'auto 0',
},
header: { display: 'flex', 'align-items': 'center', gap: '12px' },
brandName: {
'font-family': 'var(--font-display)', 'font-size': '22px', 'font-weight': '600',
color: 'var(--fg)', 'letter-spacing': '-0.015em', 'line-height': '1.15', margin: '0',
},
eyebrow: {
'font-family': 'var(--font-body)', 'font-size': '14px', 'font-weight': '500',
color: 'var(--muted)', 'text-align': 'center', margin: '-8px 0 0',
},
card: {
width: '100%', background: 'var(--bg)', border: '1px solid var(--border-soft)',
'border-radius': '18px', padding: '32px 28px', 'box-shadow': 'var(--elev-raised)',
},
footer: {
'font-family': 'var(--font-body)', 'font-size': '12px', color: 'var(--meta)',
'letter-spacing': '0.02em', 'margin-top': '4px',
},
};
+31
View File
@@ -0,0 +1,31 @@
export function CaptchaBox(props: {
image: string;
value: string;
onChange: (v: string) => void;
onRefresh: () => void;
id?: string;
}) {
const inputId = props.id ?? 'captcha-input';
return (
<div class="captcha-group">
<label for={inputId}>Captcha</label>
<div class="captcha-row">
<img
src={props.image ? `data:image/png;base64,${props.image}` : ''}
alt="Captcha"
onClick={props.onRefresh}
title="Click to refresh"
/>
<input
id={inputId}
type="text"
autocomplete="off"
placeholder="Enter captcha"
value={props.value}
onInput={(e) => props.onChange(e.currentTarget.value)}
/>
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { createSignal, onCleanup, onMount, type JSX } from 'solid-js';
import { PASSWORD_MAX_LENGTH, calcStrength, meetsPasswordPolicy } from '@/app/auth/lib/password';
function WarningCircle(props: { size: number; color: string }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill={props.color}
style={{ 'flex-shrink': '0' }}>
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-8,56a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,104a12,12,0,1,1,12-12A12,12,0,0,1,128,184Z" />
</svg>
);
}
function Eye(props: { size: number; color: string }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill={props.color}>
<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-59.27-12.14-82.34-35.23C28.75,139.87,22.81,124.64,22.09,122.63a.49.49,0,0,1,0-.26c.72-2,6.66-17.25,23.57-34.14C68.73,65.14,97.22,53,128,53s59.27,12.14,82.34,35.23c16.91,16.89,22.85,32.12,23.57,34.14a.49.49,0,0,1,0,.26c-.72,2-6.66,17.25-23.57,34.14C187.27,179.86,158.78,192,128,192Zm0-112a42,42,0,1,0,42,42A42,42,0,0,0,128,80Zm0,68a26,26,0,1,1,26-26A26,26,0,0,1,128,148Z" />
</svg>
);
}
function EyeSlash(props: { size: number; color: string }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill={props.color}>
<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38l22.59,24.7A105.07,105.07,0,0,0,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a108.07,108.07,0,0,0,48.78-11.62l25.3,27.7a8,8,0,1,0,11.84-10.76L66.4,40.37A8,8,0,0,0,53.92,34.62ZM78.29,80.44l115.42,126.3A90.81,90.81,0,0,1,128,192c-30.78,0-59.27-12.14-82.34-35.23C28.75,139.87,22.81,124.64,22.09,122.63a.49.49,0,0,1,0-.26c.72-2,6.66-17.25,23.57-34.14A89.86,89.86,0,0,1,78.29,80.44ZM128,80a41.91,41.91,0,0,1,39.9,28.82l-56.1-61.4A42.17,42.17,0,0,1,128,80Zm91.66,6.35c18.83,18.83,27.3,37.61,27.65,38.4a8,8,0,0,1,0,6.5c-.35.79-8.82,19.57-27.65,38.4A106.06,106.06,0,0,1,154.24,197l-11.33-12.41a90.71,90.71,0,0,0,38.76-27.82c16.91-16.89,22.85-32.12,23.57-34.14a.49.49,0,0,0,0-.26c-.72-2-6.66-17.25-23.57-34.14a91,91,0,0,0-28-18.51l-11.3-12.38A106,106,0,0,1,219.66,86.35Z" />
</svg>
);
}
export function ErrorMessage(props: { children: JSX.Element; id?: string }) {
let ref: HTMLDivElement | undefined;
onMount(() => {
const timeoutId = window.setTimeout(() => ref?.focus(), 0);
onCleanup(() => window.clearTimeout(timeoutId));
});
return (
<div ref={ref} id={props.id} class="error-banner" role="alert" tabIndex={-1}>
<WarningCircle size={16} color="var(--danger)" />
<span>{props.children}</span>
</div>
);
}
export function SubmitButton(props: {
loading: boolean;
disabled?: boolean;
loadingText?: string;
children: JSX.Element;
}) {
return (
<button type="submit" class="btn-primary btn-block" disabled={props.loading || props.disabled}>
{props.loading ? (
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '8px' }}>
<span class="spinner-sm" />
{props.loadingText ?? 'Please wait…'}
</span>
) : (props.children)}
</button>
);
}
interface PasswordFieldProps {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
autoComplete?: string;
autofocus?: boolean;
hasError?: boolean;
describedBy?: string;
disabled?: boolean;
maxLength?: number;
}
export function PasswordField(props: PasswordFieldProps) {
const [visible, setVisible] = createSignal(false);
return (
<div class="field">
<label for={props.id}>{props.label}</label>
<div class="password-group">
<input
id={props.id}
type={visible() ? 'text' : 'password'}
autocomplete={props.autoComplete ?? 'current-password'}
placeholder={props.placeholder ?? '••••••••'}
value={props.value}
onInput={(e) => props.onChange(e.currentTarget.value)}
autofocus={props.autofocus}
class={props.hasError ? 'input-error' : ''}
aria-invalid={props.hasError}
aria-describedby={props.describedBy}
disabled={props.disabled}
maxLength={props.maxLength ?? PASSWORD_MAX_LENGTH}
/>
<button
type="button"
onClick={() => setVisible(!visible())}
class="eye-btn"
tabIndex={-1}
aria-pressed={visible()}
aria-label={visible() ? 'Hide password' : 'Show password'}
>
{visible() ? <EyeSlash size={18} color="var(--meta)" />
: <Eye size={18} color="var(--meta)" />}
</button>
</div>
</div>
);
}
const STRENGTH_LABELS = ['', 'Weak', 'Fair', 'Strong', 'Very strong'];
export function StrengthMeter(props: { password: string }) {
if (!props.password) return null;
const score = calcStrength(props.password);
const label = STRENGTH_LABELS[score] || '';
const color = score <= 1 ? 'var(--danger)' : score === 2 ? 'var(--warn)' : 'var(--success)';
return (
<div class="strength-meter">
<div class="strength-meter__track">
<div
class="strength-meter__fill"
style={{ width: `${(score / 4) * 100}%`, background: color }}
/>
</div>
<span class="strength-meter__label" style={{ color }}>{label}</span>
</div>
);
}
export { meetsPasswordPolicy, calcStrength };
+87
View File
@@ -0,0 +1,87 @@
import { For } from 'solid-js';
interface PinCodeInputProps {
id: string;
label: string;
value: string;
length?: number;
disabled?: boolean;
autofocus?: boolean;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
}
export function PinCodeInput(props: PinCodeInputProps) {
const length = () => props.length ?? 6;
const indexes = () => Array.from({ length: length() }, (_, i) => i);
const digits = () => props.value.padEnd(length()).slice(0, length()).split('');
const inputs: HTMLInputElement[] = [];
function commit(next: string[]) {
const value = next.join('').slice(0, length());
props.onChange(value);
if (value.length === length()) props.onComplete?.(value);
}
function setFrom(index: number, raw: string) {
const values = digits();
const chars = raw.replace(/\D/g, '').slice(0, length() - index).split('');
if (chars.length === 0) {
values[index] = '';
commit(values);
return;
}
chars.forEach((char, offset) => { values[index + offset] = char; });
commit(values);
const nextIndex = Math.min(index + chars.length, length() - 1);
inputs[nextIndex]?.focus();
}
function handleKeyDown(index: number, e: KeyboardEvent) {
if (e.key !== 'Backspace') return;
const values = digits();
if (values[index]) {
values[index] = '';
commit(values);
return;
}
if (index > 0) {
e.preventDefault();
values[index - 1] = '';
commit(values);
inputs[index - 1]?.focus();
}
}
return (
<div class="field pin-field">
<label id={`${props.id}-label`}>{props.label}</label>
<div class="pin-code-row" role="group" aria-labelledby={`${props.id}-label`}>
<For each={indexes()}>{(index) => (
<input
ref={(el) => { inputs[index] = el; }}
id={index === 0 ? props.id : undefined}
class="pin-code-input"
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete={index === 0 ? 'one-time-code' : 'off'}
value={digits()[index]}
maxlength={1}
disabled={props.disabled}
autofocus={props.autofocus && index === 0}
aria-label={`Digit ${index + 1} of ${length()}`}
onInput={(e) => setFrom(index, e.currentTarget.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={(e) => {
e.preventDefault();
setFrom(index, e.clipboardData?.getData('text') ?? '');
}}
/>
)}</For>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { createSignal, onCleanup, onMount } from 'solid-js';
import { AuthService } from '@/client';
import type { ApiResponse_CaptchaResponse } from '@/client';
export function useCaptcha() {
const [captcha, setCaptcha] = createSignal({ image: '', rsaKey: null as string | null });
const [captchaError, setCaptchaError] = createSignal('');
const [captchaLoading, setCaptchaLoading] = createSignal(false);
let requestId = 0;
let disposed = false;
const refresh = async (): Promise<boolean> => {
const currentRequest = ++requestId;
setCaptchaLoading(true);
setCaptchaError('');
try {
const res: ApiResponse_CaptchaResponse = await AuthService.authGetCaptcha({
w: 200, h: 60, dark: false, rsa: true,
});
if (disposed || currentRequest !== requestId) return false;
setCaptcha({
image: res.data.base64,
rsaKey: res.data.rsa?.public_key ?? null,
});
return true;
} catch {
if (!disposed && currentRequest === requestId) {
setCaptcha({ image: '', rsaKey: null });
setCaptchaError('Captcha failed to load. Please refresh and try again.');
}
return false;
} finally {
if (!disposed && currentRequest === requestId) setCaptchaLoading(false);
}
};
onMount(() => { void refresh(); });
onCleanup(() => {
disposed = true;
requestId++;
});
return { captcha, captchaError, captchaLoading, refresh };
}
+22
View File
@@ -0,0 +1,22 @@
export const PASSWORD_MIN_LENGTH = 12;
export const PASSWORD_MAX_LENGTH = 128;
function categoryCount(pw: string): number {
const has = [/[A-Z]/.test(pw), /[a-z]/.test(pw), /\d/.test(pw), /[^a-zA-Z0-9]/.test(pw)];
return has.filter(Boolean).length;
}
export function meetsPasswordPolicy(pw: string): boolean {
return pw.length >= PASSWORD_MIN_LENGTH && categoryCount(pw) >= 3;
}
export function calcStrength(pw: string): number {
if (!pw) return 0;
let s = 0;
if (pw.length >= PASSWORD_MIN_LENGTH) s++;
if (pw.length >= 16) s++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
if (/\d/.test(pw)) s++;
if (/[^a-zA-Z0-9]/.test(pw)) s++;
return Math.min(s, 4);
}
+91
View File
@@ -0,0 +1,91 @@
function pemToDer(pem: string): Uint8Array {
const b64 = pem
.replace(/-----BEGIN (?:RSA |)PUBLIC KEY-----/g, '')
.replace(/-----END (?:RSA |)PUBLIC KEY-----/g, '')
.replace(/\s/g, '');
if (!b64) throw new Error('Invalid RSA public key');
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
function derLength(length: number): Uint8Array {
if (length < 0x80) return new Uint8Array([length]);
const bytes: number[] = [];
let value = length;
while (value > 0) {
bytes.unshift(value & 0xff);
value >>= 8;
}
return new Uint8Array([0x80 | bytes.length, ...bytes]);
}
function derTagged(tag: number, value: Uint8Array): Uint8Array {
const length = derLength(value.length);
const result = new Uint8Array(1 + length.length + value.length);
result[0] = tag;
result.set(length, 1);
result.set(value, 1 + length.length);
return result;
}
function concatBytes(...parts: Uint8Array[]): Uint8Array {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const result = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.length);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function pkcs1DerToSpkiDer(pkcs1: Uint8Array): ArrayBuffer {
const rsaEncryptionOid = new Uint8Array([
0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
]);
const nullParams = new Uint8Array([0x05, 0x00]);
const algorithmIdentifier = derTagged(0x30, concatBytes(rsaEncryptionOid, nullParams));
const publicKeyBitString = derTagged(0x03, concatBytes(new Uint8Array([0x00]), pkcs1));
return toArrayBuffer(derTagged(0x30, concatBytes(algorithmIdentifier, publicKeyBitString)));
}
async function importRsaPublicKey(pem: string): Promise<CryptoKey> {
const der = pemToDer(pem);
try {
return await crypto.subtle.importKey(
'spki', toArrayBuffer(der),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false, ['encrypt'],
);
} catch {
return await crypto.subtle.importKey(
'spki', pkcs1DerToSpkiDer(der),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false, ['encrypt'],
);
}
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
export async function rsaEncrypt(plaintext: string, publicKeyPem: string): Promise<string> {
const key = await importRsaPublicKey(publicKeyPem);
const encoded = new TextEncoder().encode(plaintext);
const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, encoded);
return bytesToBase64(new Uint8Array(encrypted));
}
+106
View File
@@ -0,0 +1,106 @@
import { createSignal, Show } from 'solid-js';
import { useNavigate, useLocation } from '@solidjs/router';
import AuthLayout from '@/app/auth/components/AuthLayout';
import { SubmitButton, ErrorMessage } from '@/app/auth/components/FormElements';
import { AuthService } from '@/client';
import { ApiError } from '@/client/core/ApiError';
function ArrowLeft(props: { size: number }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill="currentColor">
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
</svg>
);
}
function Envelope(props: { size: number; color: string }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill={props.color}>
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,27.31V192H40V75.19l84.42,73.87a8,8,0,0,0,10.52.05ZM40,64l88,77,88-77v0H40Z" />
</svg>
);
}
export default function ForgotPassword() {
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = createSignal('');
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
const isSent = () => location.hash === '#sent';
function setStepSent() { navigate('/forgot-password#sent', { replace: true }); }
function setStepForm() { navigate('/forgot-password', { replace: true }); }
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (loading()) return;
setError('');
if (!email().trim()) { setError('Please enter your email'); return; }
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRe.test(email().trim())) { setError('Invalid email format'); return; }
setLoading(true);
try {
await AuthService.authRequestPasswordReset({ requestBody: { email: email().trim() } });
setStepSent();
} catch (err) {
if (err instanceof ApiError && err.status === 429) {
setError('Too many requests, please try again later');
} else {
setError('Unable to request password reset, please try again');
}
} finally { setLoading(false); }
}
return (
<Show
when={!isSent()}
fallback={
<AuthLayout eyebrow="Reset Password">
<div style={{ display: 'flex', 'flex-direction': 'column', 'align-items': 'center', gap: '16px', 'text-align': 'center' }}>
<div class="status-icon status-icon--accent">
<Envelope size={32} color="var(--accent)" />
</div>
<h1 class="form-header">Email Sent</h1>
<p style={{ 'font-family': 'var(--font-body)', 'font-size': '15px', color: 'var(--fg)', 'line-height': '1.5', margin: '0' }}>
If {email() || 'the email address'} is registered, you will receive a password reset email.
</p>
<p style={{ 'font-family': 'var(--font-body)', 'font-size': '13px', color: 'var(--meta)', 'line-height': '1.45', margin: '0' }}>
Please check your inbox and spam folder. The link is valid for 1 hour.
</p>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', width: '100%', 'margin-top': '4px' }}>
<a href="/login" style={{ 'text-decoration': 'none' }}>
<button type="button" class="btn-primary btn-block">Back to Sign In</button>
</a>
<button type="button" class="btn-secondary btn-block"
onClick={() => { setStepForm(); setError(''); }}>Change email</button>
</div>
</div>
</AuthLayout>
}
>
<AuthLayout eyebrow="Reset Password">
<form onSubmit={handleSubmit} class="form-stack" noValidate>
<h1 class="form-header">Forgot Password</h1>
<p class="form-desc">Enter your registered email to receive a password reset link.</p>
{error() && <ErrorMessage>{error()}</ErrorMessage>}
<div class="field">
<label for="email-input">Email</label>
<input id="email-input" type="email" autocomplete="email" placeholder="your@email.com"
value={email()} onInput={(e) => setEmail(e.currentTarget.value)} autofocus />
</div>
<SubmitButton loading={loading()} loadingText="Sending…">Send Reset Link</SubmitButton>
<div style={{ display: 'flex', 'justify-content': 'center' }}>
<a href="/login" class="back-link"><ArrowLeft size={14} /> Back to Sign In</a>
</div>
</form>
</AuthLayout>
</Show>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { createSignal, Show } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
import AuthLayout from '@/app/auth/components/AuthLayout';
import { PasswordField, SubmitButton, ErrorMessage } from '@/app/auth/components/FormElements';
import { CaptchaBox } from '@/app/auth/components/CaptchaBox';
import { PinCodeInput } from '@/app/auth/components/PinCodeInput';
import { useCaptcha } from '@/app/auth/hooks/useCaptcha';
import { PASSWORD_MAX_LENGTH } from '@/app/auth/lib/password';
import { rsaEncrypt } from '@/app/auth/lib/rsa';
import { AuthService, type LoginParams } from '@/client';
import { ApiError } from '@/client/core/ApiError';
const ERROR_ID = 'login-error';
function isSafeLocalRedirect(value: string): boolean {
return value.startsWith('/') && !value.startsWith('//') && !value.includes('\\');
}
export default function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const {
captcha, captchaError, captchaLoading, refresh: refreshCaptcha,
} = useCaptcha();
const [loginValue, setLoginValue] = createSignal('');
const [password, setPassword] = createSignal('');
const [captchaText, setCaptchaText] = createSignal('');
const [totpCode, setTotpCode] = createSignal('');
const [show2FA, setShow2FA] = createSignal(false);
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
function handleCaptchaRefresh() {
setCaptchaText('');
void refreshCaptcha();
}
function getRedirectTo(): string {
const r = searchParams.redirect;
if (typeof r === 'string' && isSafeLocalRedirect(r)) return r;
return '/';
}
async function submitLogin(totpOverride?: string) {
if (loading()) return;
setError('');
const totp = (totpOverride ?? totpCode()).trim();
if (!loginValue().trim()) { setError('Please enter your username or email'); return; }
if (!password()) { setError('Please enter your password'); return; }
if (password().length > PASSWORD_MAX_LENGTH) { setError('Password is too long'); return; }
if (show2FA() && !/^\d{6}$/.test(totp)) {
setError('Please enter a valid 6-digit 2FA code');
return;
}
if (!show2FA()) {
if (captchaLoading()) { setError('Captcha is still loading'); return; }
if (captchaError()) { setError(captchaError()); return; }
if (!captchaText().trim()) { setError('Please enter the captcha'); return; }
}
setLoading(true);
try {
let rsaKey = captcha().rsaKey;
if (!rsaKey) {
const rsaRes = await AuthService.authGetRsaPublicKey();
rsaKey = rsaRes.data.public_key;
}
const encrypted = await rsaEncrypt(password(), rsaKey);
const body: LoginParams = {
username: loginValue().trim(),
password: encrypted,
captcha: show2FA() ? '' : captchaText().trim(),
};
if (show2FA()) body.totp_code = totp;
await AuthService.authLogin({ requestBody: body });
navigate(getRedirectTo(), { replace: true });
} catch (err) {
if (err instanceof ApiError) {
const msg = String(err.body?.error ?? err.message ?? '').toLowerCase();
if (msg.includes('two-factor') || msg.includes('2fa') || msg.includes('totp')) {
setShow2FA(true);
setTotpCode('');
setError('Please enter your 2FA code');
} else if (msg.includes('captcha')) {
setError('Invalid captcha, please try again');
handleCaptchaRefresh();
} else if (err.status === 404 || err.status === 400) {
setError('Invalid username, password, or verification code');
} else if (err.status === 429) {
setError('Too many attempts, please try again later');
} else {
setError('Login failed, please try again');
}
} else {
setError('Unable to complete login, please try again');
}
} finally {
setLoading(false);
}
}
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
void submitLogin();
}
function handleTotpComplete(code: string) {
if (!loading()) void submitLogin(code);
}
return (
<AuthLayout eyebrow="Sign in to your account">
<form onSubmit={handleSubmit} class="form-stack" noValidate>
<h1 class="form-header">Sign In</h1>
{(error() || (!show2FA() && captchaError())) && (
<ErrorMessage id={ERROR_ID}>{error() || captchaError()}</ErrorMessage>
)}
<div class="field">
<label for="login-input">Username or Email</label>
<input
id="login-input"
type="text"
autocomplete="username"
placeholder="Enter your username or email"
value={loginValue()}
onInput={(e) => setLoginValue(e.currentTarget.value)}
autofocus
/>
</div>
<PasswordField
id="password-input"
label="Password"
value={password()}
onChange={setPassword}
autoComplete="current-password"
hasError={!!error()}
describedBy={error() ? ERROR_ID : undefined}
/>
<Show when={show2FA()}>
<PinCodeInput
id="totp-input"
label="Two-Factor Code"
value={totpCode()}
onChange={setTotpCode}
onComplete={handleTotpComplete}
disabled={loading()}
autofocus
/>
</Show>
<Show when={!show2FA()}>
<CaptchaBox
id="login-captcha"
image={captcha().image}
value={captchaText()}
onChange={setCaptchaText}
onRefresh={handleCaptchaRefresh}
/>
</Show>
<div class="forgot-link-row">
<a href="/forgot-password" class="back-link" style={{ color: 'var(--accent)' }}>
Forgot password?
</a>
</div>
<SubmitButton
loading={loading()}
loadingText="Signing in…"
disabled={!show2FA() && (captchaLoading() || !!captchaError())}
>
Sign In
</SubmitButton>
<div class="auth-divider"><span>or</span></div>
<a href="/register" style={{ 'text-decoration': 'none' }}>
<button type="button" class="btn-secondary btn-block">Create an account</button>
</a>
</form>
</AuthLayout>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { createSignal } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
import AuthLayout from '@/app/auth/components/AuthLayout';
import { PasswordField, SubmitButton, ErrorMessage, meetsPasswordPolicy } from '@/app/auth/components/FormElements';
import { CaptchaBox } from '@/app/auth/components/CaptchaBox';
import { useCaptcha } from '@/app/auth/hooks/useCaptcha';
import { PASSWORD_MAX_LENGTH } from '@/app/auth/lib/password';
import { rsaEncrypt } from '@/app/auth/lib/rsa';
import { AuthService, type RegisterEmailCodeParams, type RegisterParams } from '@/client';
import { ApiError } from '@/client/core/ApiError';
const ERROR_ID = 'register-error';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isSafeLocalRedirect(value: string): boolean {
return value.startsWith('/') && !value.startsWith('//') && !value.includes('\\');
}
export default function Register() {
const navigate = useNavigate();
const {
captcha, captchaError, captchaLoading, refresh: refreshCaptcha,
} = useCaptcha();
const [form, setForm] = createSignal({
username: '', email: '', password: '', confirmPassword: '', emailCode: '',
});
const [captchaText, setCaptchaText] = createSignal('');
const [step, setStep] = createSignal<'email' | 'register'>('email');
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [codeLoading, setCodeLoading] = createSignal(false);
const [searchParams] = useSearchParams();
function getRedirectTo(): string {
const r = searchParams.redirect;
if (typeof r === 'string' && isSafeLocalRedirect(r)) return r;
return '/';
}
function setField(key: keyof ReturnType<typeof form>) {
return (e: InputEvent & { currentTarget: HTMLInputElement }) =>
setForm((prev) => ({ ...prev, [key]: e.currentTarget.value }));
}
function handleCaptchaRefresh() {
setCaptchaText('');
void refreshCaptcha();
}
function validateCaptcha(): boolean {
if (captchaLoading()) { setError('Captcha is still loading'); return false; }
if (captchaError()) { setError(captchaError()); return false; }
if (!captchaText().trim()) { setError('Please enter the captcha'); return false; }
return true;
}
async function handleSendCode(e: MouseEvent) {
e.preventDefault();
if (codeLoading()) return;
setError('');
const email = form().email.trim();
if (!email) { setError('Please enter your email'); return; }
if (!EMAIL_RE.test(email)) { setError('Invalid email format'); return; }
if (!validateCaptcha()) return;
setCodeLoading(true);
try {
const requestBody: RegisterEmailCodeParams = { email, captcha: captchaText().trim() };
await AuthService.authSendRegisterEmailCode({ requestBody });
setStep('register');
handleCaptchaRefresh();
} catch (err) {
if (err instanceof ApiError) {
const msg = String(err.body?.error ?? err.message ?? '').toLowerCase();
if (msg.includes('captcha') || err.status === 400) {
setError('Invalid captcha or email, please try again');
handleCaptchaRefresh();
} else if (err.status === 409) {
setError('This email is already used');
} else if (err.status === 429) {
setError('Too many requests, please try again later');
} else {
setError('Failed to send code, please try again');
}
} else {
setError('Unable to send code, please try again');
}
} finally {
setCodeLoading(false);
}
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (loading()) return;
setError('');
const f = form();
if (!f.username.trim()) { setError('Please enter a username'); return; }
if (!f.email.trim()) { setError('Please enter your email'); return; }
if (!f.emailCode.trim()) { setError('Please enter the verification code'); return; }
if (!/^\d{6}$/.test(f.emailCode.trim())) { setError('Please enter a valid 6-digit code'); return; }
if (!f.password) { setError('Please enter a password'); return; }
if (f.password.length > PASSWORD_MAX_LENGTH) { setError('Password is too long'); return; }
if (!meetsPasswordPolicy(f.password)) {
setError('Password must be at least 12 characters with 3 of: uppercase, lowercase, digit, special character');
return;
}
if (f.password !== f.confirmPassword) { setError('Passwords do not match'); return; }
if (!EMAIL_RE.test(f.email.trim())) { setError('Invalid email format'); return; }
if (!validateCaptcha()) return;
setLoading(true);
try {
let rsaKey = captcha().rsaKey;
if (!rsaKey) {
const rsaRes = await AuthService.authGetRsaPublicKey();
rsaKey = rsaRes.data.public_key;
}
const encrypted = await rsaEncrypt(f.password, rsaKey);
const requestBody: RegisterParams = {
username: f.username.trim(),
email: f.email.trim(),
password: encrypted,
captcha: captchaText().trim(),
email_code: f.emailCode.trim(),
};
await AuthService.authRegister({ requestBody });
navigate(getRedirectTo(), { replace: true });
} catch (err) {
if (err instanceof ApiError) {
const msg = String(err.body?.error ?? err.message ?? '').toLowerCase();
if (msg.includes('captcha') || err.status === 400) {
setError('Invalid captcha, code, or password. Please try again');
handleCaptchaRefresh();
} else if (err.status === 409) {
setError('Username or email is already taken');
} else if (err.status === 429) {
setError('Too many attempts, please try again later');
} else {
setError('Registration failed, please try again');
}
} else {
setError('Unable to complete registration, please try again');
}
} finally {
setLoading(false);
}
}
if (step() === 'email') {
return (
<AuthLayout eyebrow="Create a new account">
<div class="form-stack" style={{ gap: '20px' }}>
<h1 class="form-header">Register</h1>
<p class="form-desc">Enter your email to receive a verification code.</p>
{(error() || captchaError()) && <ErrorMessage id={ERROR_ID}>{error() || captchaError()}</ErrorMessage>}
<div class="field">
<label for="reg-email">Email</label>
<input id="reg-email" type="email" autocomplete="email" placeholder="your@email.com"
value={form().email} onInput={setField('email')} autofocus />
</div>
<CaptchaBox
id="register-email-captcha"
image={captcha().image}
value={captchaText()}
onChange={setCaptchaText}
onRefresh={handleCaptchaRefresh}
/>
<button
type="button"
class="btn-primary btn-block"
disabled={codeLoading() || captchaLoading() || !!captchaError()}
onClick={handleSendCode}
>
{codeLoading() ? (
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '8px' }}>
<span class="spinner-sm" /> Sending
</span>
) : 'Send Code'}
</button>
<div class="auth-divider"><span>or</span></div>
<a href="/login" style={{ 'text-decoration': 'none' }}>
<button type="button" class="btn-secondary btn-block">Already have an account? Sign in</button>
</a>
</div>
</AuthLayout>
);
}
return (
<AuthLayout eyebrow="Create a new account" maxWidth={520}>
<form onSubmit={handleSubmit} class="form-stack" noValidate style={{ gap: '14px' }}>
<h1 class="form-header">Register</h1>
{(error() || captchaError()) && <ErrorMessage id={ERROR_ID}>{error() || captchaError()}</ErrorMessage>}
<div class="field">
<label for="reg-email">Email</label>
<input id="reg-email" type="email" autocomplete="email" value={form().email} disabled />
</div>
<div class="field">
<label for="reg-code">Verification Code</label>
<input id="reg-code" type="text" inputmode="numeric" autocomplete="off"
placeholder="Enter 6-digit code" value={form().emailCode}
onInput={setField('emailCode')} autofocus maxlength={6} />
</div>
<div class="field">
<label for="reg-username">Username</label>
<input id="reg-username" type="text" autocomplete="username" placeholder="Choose a username"
value={form().username} onInput={setField('username')} />
</div>
<PasswordField id="password" label="Password" value={form().password}
onChange={(v) => setForm((p) => ({ ...p, password: v }))}
autoComplete="new-password" placeholder="At least 12 characters"
hasError={!!error()} describedBy={error() ? ERROR_ID : undefined} />
<PasswordField id="confirm-password" label="Confirm Password"
value={form().confirmPassword}
onChange={(v) => setForm((p) => ({ ...p, confirmPassword: v }))}
autoComplete="new-password" placeholder="Re-enter your password"
hasError={!!error()} />
<p class="form-hint">
At least 12 characters with 3 of: uppercase, lowercase, digit, special character.
</p>
<CaptchaBox
id="register-final-captcha"
image={captcha().image}
value={captchaText()}
onChange={setCaptchaText}
onRefresh={handleCaptchaRefresh}
/>
<SubmitButton
loading={loading()}
loadingText="Creating account…"
disabled={captchaLoading() || !!captchaError()}
>Register</SubmitButton>
<div class="auth-divider"><span>or</span></div>
<a href="/login" style={{ 'text-decoration': 'none' }}>
<button type="button" class="btn-secondary btn-block">Already have an account? Sign in</button>
</a>
</form>
</AuthLayout>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { createSignal } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import AuthLayout from '@/app/auth/components/AuthLayout';
import { PasswordField, SubmitButton, ErrorMessage, StrengthMeter, meetsPasswordPolicy } from '@/app/auth/components/FormElements';
import { PASSWORD_MAX_LENGTH } from '@/app/auth/lib/password';
import { rsaEncrypt } from '@/app/auth/lib/rsa';
import { AuthService } from '@/client';
import { ApiError } from '@/client/core/ApiError';
function WarningCircle(props: { size: number; color: string }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill={props.color}>
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm-8,56a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm8,104a12,12,0,1,1,12-12A12,12,0,0,1,128,184Z" />
</svg>
);
}
function CheckCircle(props: { size: number; color: string }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill={props.color}>
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z" />
</svg>
);
}
function ArrowLeft(props: { size: number }) {
return (
<svg width={props.size} height={props.size} viewBox="0 0 256 256" fill="currentColor">
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
</svg>
);
}
function InvalidTokenView() {
return (
<AuthLayout eyebrow="Reset Password">
<div style={{ display: 'flex', 'flex-direction': 'column', 'align-items': 'center', gap: '16px', 'text-align': 'center' }}>
<div class="status-icon status-icon--warn">
<WarningCircle size={32} color="var(--warn)" />
</div>
<h1 class="form-header">Invalid Link</h1>
<p class="form-desc">This password reset link is invalid or has expired. Please request a new one.</p>
<a href="/forgot-password" style={{ 'text-decoration': 'none', width: '100%' }}>
<button type="button" class="btn-primary btn-block">Request New Link</button>
</a>
<a href="/login" class="back-link" style={{ 'margin-top': '4px' }}>
<ArrowLeft size={14} /> Back to Sign In
</a>
</div>
</AuthLayout>
);
}
function SuccessView() {
return (
<AuthLayout eyebrow="Reset Password">
<div style={{ display: 'flex', 'flex-direction': 'column', 'align-items': 'center', gap: '16px', 'text-align': 'center' }}>
<div class="status-icon status-icon--success">
<CheckCircle size={32} color="var(--success)" />
</div>
<h1 class="form-header">Password Reset</h1>
<p style={{ 'font-family': 'var(--font-body)', 'font-size': '15px', color: 'var(--fg)', 'line-height': '1.5', margin: '0' }}>
Your password has been successfully reset. Please sign in with your new password.
</p>
<a href="/login" style={{ 'text-decoration': 'none', width: '100%' }}>
<button type="button" class="btn-primary btn-block">Go to Sign In</button>
</a>
</div>
</AuthLayout>
);
}
function ResetFormView(props: { token: string }) {
const [password, setPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [success, setSuccess] = createSignal(false);
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (loading()) return;
setError('');
if (!password()) { setError('Please enter a new password'); return; }
if (password().length > PASSWORD_MAX_LENGTH) { setError('Password is too long'); return; }
if (!meetsPasswordPolicy(password())) {
setError('Password must be at least 12 characters with 3 of: uppercase, lowercase, digit, special character');
return;
}
if (password() !== confirmPassword()) { setError('Passwords do not match'); return; }
setLoading(true);
try {
const rsaRes = await AuthService.authGetRsaPublicKey();
const encrypted = await rsaEncrypt(password(), rsaRes.data.public_key);
await AuthService.authVerifyPasswordReset({
requestBody: { token: props.token, password: encrypted },
});
setSuccess(true);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 400) {
setError('Link is invalid or expired. Please request a new one.');
} else if (err.status === 429) {
setError('Too many attempts, please try again later');
} else {
setError('Reset failed, please try again');
}
} else {
setError('Unable to reset password, please try again');
}
} finally { setLoading(false); }
}
if (success()) return <SuccessView />;
return (
<AuthLayout eyebrow="Set a new password">
<form onSubmit={handleSubmit} class="form-stack" noValidate>
<h1 class="form-header">Reset Password</h1>
<p class="form-desc">Enter your new password.</p>
{error() && <ErrorMessage>{error()}</ErrorMessage>}
<PasswordField id="new-password" label="New Password" value={password()}
onChange={setPassword} autoComplete="new-password" placeholder="At least 12 characters"
autofocus />
<PasswordField id="confirm-password" label="Confirm Password" value={confirmPassword()}
onChange={setConfirmPassword} autoComplete="new-password" placeholder="Re-enter new password" />
<StrengthMeter password={password()} />
<SubmitButton loading={loading()} loadingText="Resetting…">Reset Password</SubmitButton>
<div style={{ display: 'flex', 'justify-content': 'center' }}>
<a href="/login" class="back-link"><ArrowLeft size={14} /> Back to Sign In</a>
</div>
</form>
</AuthLayout>
);
}
export default function ResetPassword() {
const [searchParams] = useSearchParams();
function getToken(): string {
const t = searchParams.token;
if (typeof t === 'string') return t;
return '';
}
const token = getToken();
if (!token) return <InvalidTokenView />;
return <ResetFormView token={token} />;
}