Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
"@fontsource-variable/jetbrains-mono": "^5.1.1",
"@fontsource-variable/public-sans": "^5.1.0",
"@tanstack/react-query": "^5.51.1",
"i18next": "^26.3.6",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
Expand Down
21 changes: 12 additions & 9 deletions ui/src/auth/ChangePasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiError } from '../api/client'
import { apiErrorMessage } from '../i18n/apiError'
import { Button, Field, Input } from '../components/ui'
import { useAuth } from './AuthContext'

Expand All @@ -11,12 +13,13 @@ const MIN_LENGTH = 12
* and the voluntary change modal.
*/
export function ChangePasswordForm({
submitLabel = 'Update password',
submitLabel,
onSuccess,
}: {
submitLabel?: string
onSuccess?: () => void
}) {
const { t } = useTranslation()
const { changePassword } = useAuth()
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
Expand All @@ -37,14 +40,14 @@ export function ChangePasswordForm({
await changePassword(current, next)
onSuccess?.()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not change the password.')
setError(err instanceof ApiError ? apiErrorMessage(err, t) : t('auth.password.failed'))
setBusy(false)
}
}

return (
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<Field label="Current password" htmlFor="cp-current">
<Field label={t('auth.password.current')} htmlFor="cp-current">
<Input
id="cp-current"
type="password"
Expand All @@ -55,10 +58,10 @@ export function ChangePasswordForm({
/>
</Field>
<Field
label="New password"
label={t('auth.password.new')}
htmlFor="cp-new"
hint={`At least ${MIN_LENGTH} characters.`}
error={tooShort ? `Use at least ${MIN_LENGTH} characters.` : undefined}
hint={t('auth.password.hint', { min: MIN_LENGTH })}
error={tooShort ? t('auth.password.tooShort', { min: MIN_LENGTH }) : undefined}
>
<Input
id="cp-new"
Expand All @@ -69,9 +72,9 @@ export function ChangePasswordForm({
/>
</Field>
<Field
label="Confirm new password"
label={t('auth.password.confirm')}
htmlFor="cp-confirm"
error={mismatch ? 'Passwords do not match.' : undefined}
error={mismatch ? t('auth.password.mismatch') : undefined}
>
<Input
id="cp-confirm"
Expand All @@ -87,7 +90,7 @@ export function ChangePasswordForm({
</p>
)}
<Button type="submit" loading={busy} disabled={!canSubmit} className="w-full">
{busy ? 'Saving…' : submitLabel}
{busy ? t('common.saving') : (submitLabel ?? t('auth.password.update'))}
</Button>
</form>
)
Expand Down
8 changes: 5 additions & 3 deletions ui/src/auth/ChangePasswordModal.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useTranslation } from 'react-i18next'
import { Modal, useToast } from '../components/ui'
import { ChangePasswordForm } from './ChangePasswordForm'

/** Voluntary password change, launched from the Topbar (dismissable, unlike the forced screen). */
export function ChangePasswordModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation()
const toast = useToast()
return (
<Modal open={open} onClose={onClose} title="Change password">
<Modal open={open} onClose={onClose} title={t('auth.password.changeTitle')}>
<ChangePasswordForm
submitLabel="Update password"
submitLabel={t('auth.password.update')}
onSuccess={() => {
toast.success('Password updated.')
toast.success(t('auth.password.updated'))
onClose()
}}
/>
Expand Down
16 changes: 11 additions & 5 deletions ui/src/auth/ChangePasswordScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { KeyRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ChangePasswordForm } from './ChangePasswordForm'
import { useAuth } from './AuthContext'

Expand All @@ -8,6 +9,7 @@ import { useAuth } from './AuthContext'
* `changePassword` clears the flag and the app renders.
*/
export function ChangePasswordScreen() {
const { t } = useTranslation()
const { currentUser, logout } = useAuth()
return (
<div className="flex min-h-screen items-center justify-center bg-canvas px-4">
Expand All @@ -16,19 +18,23 @@ export function ChangePasswordScreen() {
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-card bg-accent-weak text-accent">
<KeyRound className="h-6 w-6" />
</div>
<h1 className="text-lg font-semibold tracking-tight text-ink">Set a new password</h1>
<h1 className="text-lg font-semibold tracking-tight text-ink">
{t('auth.password.setTitle')}
</h1>
<p className="mt-1 text-[13px] text-ink-muted">
{currentUser?.username ? `Signed in as ${currentUser.username}. ` : ''}
Choose a new password to finish signing in.
{currentUser?.username
? t('auth.password.signedInAs', { name: currentUser.username })
: ''}
{t('auth.password.chooseNew')}
</p>
</div>
<ChangePasswordForm submitLabel="Set password & continue" />
<ChangePasswordForm submitLabel={t('auth.password.setContinue')} />
<button
type="button"
onClick={logout}
className="mt-4 w-full text-center text-xs text-ink-faint hover:text-ink-muted"
>
Sign out
{t('topbar.signOut')}
</button>
</div>
</div>
Expand Down
28 changes: 15 additions & 13 deletions ui/src/auth/LoginScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { LogIn } from 'lucide-react'
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import { ApiError } from '../api/client'
import { apiErrorMessage } from '../i18n/apiError'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { useAuth } from './AuthContext'
import { ApiReachLine, UnlockScreen, useApiReach } from './UnlockScreen'

/** Maps a login failure to a coarse, credential-safe message. */
function loginError(err: unknown): string {
if (err instanceof ApiError) {
return err.status === 401 ? 'Invalid username or password.' : err.message
}
return 'Could not reach the API. Is the stack running?'
function loginError(err: unknown, t: TFunction): string {
if (err instanceof ApiError && err.status === 401) return t('auth.login.invalid')
return apiErrorMessage(err, t)
}

/**
* Primary sign-in: username + password. Offers a "use master key" toggle that
* swaps in the bootstrap {@link UnlockScreen} (break-glass admin access).
*/
export function LoginScreen() {
const { t } = useTranslation()
const { login } = useAuth()
const { reach, health } = useApiReach()
const [useMaster, setUseMaster] = useState(false)
Expand All @@ -42,7 +44,7 @@ export function LoginScreen() {
try {
await login(username.trim(), password)
} catch (err) {
setError(loginError(err))
setError(loginError(err, t))
setBusy(false)
}
}
Expand All @@ -55,23 +57,23 @@ export function LoginScreen() {
<LogIn className="h-6 w-6" />
</div>
<h1 className="text-lg font-semibold tracking-tight text-ink">EmbedBase</h1>
<p className="mt-1 text-[13px] text-ink-muted">Sign in to your account.</p>
<p className="mt-1 text-[13px] text-ink-muted">{t('auth.login.subtitle')}</p>
</div>

<form onSubmit={onSubmit} className="flex flex-col gap-3">
<Input
ref={inputRef}
autoComplete="username"
placeholder="Username"
aria-label="Username"
placeholder={t('auth.field.username')}
aria-label={t('auth.field.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="password"
autoComplete="current-password"
placeholder="Password"
aria-label="Password"
placeholder={t('auth.field.password')}
aria-label={t('auth.field.password')}
aria-invalid={error != null}
value={password}
onChange={(e) => setPassword(e.target.value)}
Expand All @@ -87,7 +89,7 @@ export function LoginScreen() {
disabled={!username.trim() || !password}
className="w-full"
>
{busy ? 'Signing in…' : 'Sign in'}
{busy ? t('auth.login.submitting') : t('auth.login.submit')}
</Button>
</form>

Expand All @@ -96,7 +98,7 @@ export function LoginScreen() {
onClick={() => setUseMaster(true)}
className="mt-4 w-full text-center text-xs text-ink-faint hover:text-ink-muted"
>
Use master key instead
{t('auth.login.useMaster')}
</button>

<ApiReachLine reach={reach} health={health} />
Expand Down
Loading
Loading