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:
@@ -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); }
|
||||
}
|
||||
@@ -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}>
|
||||
© {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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user