backend integration started
This commit is contained in:
@@ -1,19 +1,23 @@
|
||||
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import { PasswordSetupRequiredError } from '@/auth/session';
|
||||
import type { ConsoleRole, SessionUser } from '@/auth/roles';
|
||||
|
||||
/**
|
||||
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
|
||||
* production build this expression is `null`, the import is dead code, and
|
||||
* neither the panel nor the accounts behind it are emitted at all.
|
||||
*/
|
||||
const DemoPanel = import.meta.env.DEV
|
||||
? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel })))
|
||||
: null;
|
||||
import {
|
||||
MIN_PASSWORD_LENGTH,
|
||||
PasswordSetupRequiredError,
|
||||
setInitialPassword,
|
||||
} from '@/auth/session';
|
||||
|
||||
/**
|
||||
* Sign-in — KROW's full-bleed auth archetype.
|
||||
@@ -29,16 +33,27 @@ const DemoPanel = import.meta.env.DEV
|
||||
* lands where their account says they belong.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const { user, signIn, signInAsDemo } = useAuth();
|
||||
const { user, signIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isRemembered, setIsRemembered] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
/**
|
||||
* The userid `applogin` returned for an account with no password, or null.
|
||||
*
|
||||
* Non-null flips this page into its second state. It is deliberately not a
|
||||
* route: the userid only exists because a sign-in attempt just produced it,
|
||||
* and a `/set-password` URL that could be opened cold would be a way to set
|
||||
* any account's password from nothing.
|
||||
*/
|
||||
const [setupUserid, setSetupUserid] = useState<number | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
@@ -49,19 +64,52 @@ export function LoginPage() {
|
||||
const session = await signIn(email, password);
|
||||
navigate(HOME_ROUTE[session.role], { replace: true });
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof PasswordSetupRequiredError
|
||||
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: 'Sign-in failed',
|
||||
);
|
||||
// Not a failure — the first step. The account exists and has no password,
|
||||
// and the backend handed back the userid to set one against.
|
||||
if (cause instanceof PasswordSetupRequiredError) {
|
||||
setSetupUserid(cause.userid);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} else {
|
||||
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
|
||||
}
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the password, then sign in with it.
|
||||
*
|
||||
* Signing in afterwards rather than sending the person back to the form: they
|
||||
* have just typed the password twice, and `applogin` is the only proof the
|
||||
* write actually took.
|
||||
*/
|
||||
async function handleSetup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (setupUserid === null) return;
|
||||
setError(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Those two passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await setInitialPassword(setupUserid, newPassword);
|
||||
const session = await signIn(email, newPassword);
|
||||
navigate(HOME_ROUTE[session.role], { replace: true });
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Could not set the password');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
|
||||
const canSetup =
|
||||
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -81,24 +129,38 @@ export function LoginPage() {
|
||||
}}
|
||||
>
|
||||
<BrandPanel />
|
||||
<FormPanel
|
||||
email={email}
|
||||
password={password}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
isRemembered={isRemembered}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSubmit}
|
||||
onEmail={setEmail}
|
||||
onPassword={setPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
|
||||
onSubmit={handleSubmit}
|
||||
onDemo={(role, session) => {
|
||||
signInAsDemo(session);
|
||||
navigate(HOME_ROUTE[role], { replace: true });
|
||||
}}
|
||||
/>
|
||||
{setupUserid === null ? (
|
||||
<FormPanel
|
||||
email={email}
|
||||
password={password}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSubmit}
|
||||
onEmail={setEmail}
|
||||
onPassword={setPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : (
|
||||
<SetupPanel
|
||||
email={email}
|
||||
newPassword={newPassword}
|
||||
confirmPassword={confirmPassword}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSetup}
|
||||
onNewPassword={setNewPassword}
|
||||
onConfirmPassword={setConfirmPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onSubmit={handleSetup}
|
||||
onBack={() => {
|
||||
setSetupUserid(null);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -238,32 +300,26 @@ interface FormPanelProps {
|
||||
email: string;
|
||||
password: string;
|
||||
isPasswordVisible: boolean;
|
||||
isRemembered: boolean;
|
||||
error: string | null;
|
||||
isBusy: boolean;
|
||||
canSubmit: boolean;
|
||||
onEmail: (value: string) => void;
|
||||
onPassword: (value: string) => void;
|
||||
onToggleVisible: () => void;
|
||||
onToggleRemember: () => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onDemo: (role: ConsoleRole, session: SessionUser) => void;
|
||||
}
|
||||
|
||||
function FormPanel({
|
||||
email,
|
||||
password,
|
||||
isPasswordVisible,
|
||||
isRemembered,
|
||||
error,
|
||||
isBusy,
|
||||
canSubmit,
|
||||
onEmail,
|
||||
onPassword,
|
||||
onToggleVisible,
|
||||
onToggleRemember,
|
||||
onSubmit,
|
||||
onDemo,
|
||||
}: FormPanelProps) {
|
||||
return (
|
||||
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
||||
@@ -311,21 +367,7 @@ function FormPanel({
|
||||
type="button"
|
||||
onClick={onToggleVisible}
|
||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={eyeButtonStyle}
|
||||
>
|
||||
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
@@ -343,104 +385,23 @@ function FormPanel({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRemembered}
|
||||
onChange={onToggleRemember}
|
||||
style={{ width: 15, height: 15, accentColor: 'var(--color-brand)', cursor: 'pointer' }}
|
||||
/>
|
||||
Keep me signed in
|
||||
</label>
|
||||
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
|
||||
|
||||
The checkbox was initialised true, toggled, rendered — and never
|
||||
read: `handleSubmit` and `signIn(email, password)` never received
|
||||
it, so ticking or clearing it changed nothing about how long the
|
||||
session lasted. The link had no handler at all, and there is no
|
||||
password-reset endpoint in Fiesta to give it one. Both promised
|
||||
something the system does not do. */}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-brand)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<ErrorNote message={error} />
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 9,
|
||||
padding: '11px 13px',
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-error-muted, #FCEEEE)',
|
||||
border: '1px solid rgba(214,69,69,.22)',
|
||||
color: '#B03434',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 9,
|
||||
height: 46,
|
||||
width: '100%',
|
||||
borderRadius: 14,
|
||||
border: 0,
|
||||
background: canSubmit ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
|
||||
color: '#fff',
|
||||
fontSize: 14.5,
|
||||
fontWeight: 600,
|
||||
cursor: canSubmit ? 'pointer' : 'default',
|
||||
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
|
||||
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
|
||||
}}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<SubmitButton
|
||||
canSubmit={canSubmit}
|
||||
isBusy={isBusy}
|
||||
busyLabel="Signing in…"
|
||||
label="Continue"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<p
|
||||
@@ -456,16 +417,289 @@ function FormPanel({
|
||||
opens — you do not pick one.
|
||||
</p>
|
||||
|
||||
{DemoPanel ? (
|
||||
<Suspense fallback={null}>
|
||||
<DemoPanel onDemo={onDemo} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Right — first sign-in, setting the password
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface SetupPanelProps {
|
||||
email: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
isPasswordVisible: boolean;
|
||||
error: string | null;
|
||||
isBusy: boolean;
|
||||
canSubmit: boolean;
|
||||
onNewPassword: (value: string) => void;
|
||||
onConfirmPassword: (value: string) => void;
|
||||
onToggleVisible: () => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The second state of this page, not a second page.
|
||||
*
|
||||
* An account created by `createtenantuser` or `createtenantlocation` is spawned
|
||||
* with an empty password, so its owner's first sign-in cannot succeed and there
|
||||
* is no reset email to fall back on. Before this existed the page detected the
|
||||
* condition and then told the person to go and find an administrator — for an
|
||||
* account that was working as designed.
|
||||
*/
|
||||
function SetupPanel({
|
||||
email,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
isPasswordVisible,
|
||||
error,
|
||||
isBusy,
|
||||
canSubmit,
|
||||
onNewPassword,
|
||||
onConfirmPassword,
|
||||
onToggleVisible,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: SetupPanelProps) {
|
||||
const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH;
|
||||
const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword;
|
||||
|
||||
return (
|
||||
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
||||
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
marginBottom: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-surface-subtle)',
|
||||
border: '1px solid var(--color-line)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.09em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={13} />
|
||||
First sign-in
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 26,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
Choose a password
|
||||
</h1>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
||||
{email} has no password yet. Set one now and we will sign you straight in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Field
|
||||
label="New password"
|
||||
htmlFor="new-password"
|
||||
icon={<Lock size={15} />}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleVisible}
|
||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||
style={eyeButtonStyle}
|
||||
>
|
||||
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<input
|
||||
id="new-password"
|
||||
type={isPasswordVisible ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(event) => onNewPassword(event.target.value)}
|
||||
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
required
|
||||
aria-invalid={isTooShort}
|
||||
style={{ ...inputStyle, paddingRight: 40 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={isPasswordVisible ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(event) => onConfirmPassword(event.target.value)}
|
||||
placeholder="Type it again"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
aria-invalid={isMismatched}
|
||||
style={{
|
||||
...inputStyle,
|
||||
borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)',
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Hint>
|
||||
{isTooShort
|
||||
? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.`
|
||||
: isMismatched
|
||||
? 'Those two do not match yet.'
|
||||
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
|
||||
</Hint>
|
||||
|
||||
<ErrorNote message={error} />
|
||||
|
||||
<SubmitButton
|
||||
canSubmit={canSubmit}
|
||||
isBusy={isBusy}
|
||||
busyLabel="Setting it…"
|
||||
label="Set password and sign in"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: 12.5,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: 3,
|
||||
}}
|
||||
>
|
||||
Use a different account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A quiet line under the fields — advisory, never an error. */
|
||||
function Hint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Shared form furniture
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function ErrorNote({ message }: { message: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 9,
|
||||
padding: '11px 13px',
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-error-muted, #FCEEEE)',
|
||||
border: '1px solid rgba(214,69,69,.22)',
|
||||
color: '#B03434',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton({
|
||||
canSubmit,
|
||||
isBusy,
|
||||
label,
|
||||
busyLabel,
|
||||
}: {
|
||||
canSubmit: boolean;
|
||||
isBusy: boolean;
|
||||
label: string;
|
||||
busyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 9,
|
||||
height: 46,
|
||||
width: '100%',
|
||||
borderRadius: 14,
|
||||
border: 0,
|
||||
background: canSubmit
|
||||
? 'var(--color-brand)'
|
||||
: 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
|
||||
color: '#fff',
|
||||
fontSize: 14.5,
|
||||
fontWeight: 600,
|
||||
cursor: canSubmit ? 'pointer' : 'default',
|
||||
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
|
||||
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
|
||||
}}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
{busyLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{label}
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const eyeButtonStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-4)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Field
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user