Add link previews and icons; make PIG_INVITE_CODE actually work
Social and icons. A 1200x630 card, apple-touch-icon, and maskable PWA icons, generated from an HTML template by a script so the mark, wordmark and tagline cannot drift from the product. The apple-touch-icon referenced in index.html was a 404 until now. Icons are drawn on an opaque plate with inset because iOS rounds corners and Android may apply a circle — an edge-to-edge mark loses its ears to that crop. og:image is absolute, which is the single most common reason a card unfurls blank. PIG_INVITE_CODE was a lie. Signup validates against the invites table, so setting the variable only flipped a label in the UI — an operator would set it, hand the code to a colleague, and watch them be rejected. It is now reconciled into a real invite row at boot: setting it issues, changing it rotates and revokes the predecessor, and removing it revokes. Verified all three, plus that a restart with an unchanged code does not duplicate. Two bugs found while doing that: - `uses_remaining` was jsonb, so the SQL decrement could never have worked. Now integer. The generated migration failed because Postgres has no implicit jsonb->integer cast, so the USING clause is hand-written. - Redemption keyed off `redeemedAt`, which would have made every reusable invite single-use — a confusing way to lock a team out. Availability now comes from `usesRemaining`, and redemption records who used it most recently without consuming it. Sign-in gains a password option alongside the magic link, defaulting to password since that is the daily path. PIG stores neither; both are handled by the identity provider and PIG only ever sees the resulting token. autocomplete is set so password managers and iOS can fill. Verified: full migration chain applies to a fresh Postgres, the CSP hash for the inline theme script is unchanged by the rebuild. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+141
-44
@@ -1,18 +1,29 @@
|
||||
/**
|
||||
* Sign-in.
|
||||
*
|
||||
* Magic link only. PIG stores no passwords, and adding a password field would
|
||||
* mean either storing one or pretending to — both worse than an email link for
|
||||
* an internal tool used by a couple of dozen people.
|
||||
* Two methods, because they suit different situations. A password is faster
|
||||
* for someone who uses PIG daily and has it in a manager; a magic link needs
|
||||
* no credential at all and is the better answer for someone signing in once
|
||||
* from a phone. Neither is stored by PIG — both are handled entirely by the
|
||||
* identity provider, and PIG only ever sees the resulting token.
|
||||
*
|
||||
* Password is the default tab because the alternative — defaulting to a link
|
||||
* and making daily users switch every time — is the more annoying of the two
|
||||
* mistakes.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { KeyRound, Mail } from 'lucide-react';
|
||||
import { getSupabase, type PublicConfig } from '@/lib/api';
|
||||
import { Button, Card, CardContent, Input } from '@/components/ui';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
|
||||
type Method = 'password' | 'link';
|
||||
|
||||
export function SignIn({ config }: { config: PublicConfig }) {
|
||||
const [method, setMethod] = useState<Method>('password');
|
||||
const [email, setEmail] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const [password, setPassword] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'busy' | 'sent' | 'error'>('idle');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
@@ -24,16 +35,31 @@ export function SignIn({ config }: { config: PublicConfig }) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('sending');
|
||||
setStatus('busy');
|
||||
setMessage('');
|
||||
|
||||
if (method === 'password') {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
// Supabase deliberately returns the same message for a wrong password
|
||||
// and an unknown address, which is correct — distinguishing them tells
|
||||
// an attacker which addresses are registered.
|
||||
setMessage(error.message);
|
||||
return;
|
||||
}
|
||||
// The auth state listener in App.tsx picks this up and re-fetches; no
|
||||
// navigation is needed here.
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: { emailRedirectTo: window.location.origin },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
// Supabase returns a clear message for rate limits and disabled signup,
|
||||
// both of which the person can act on, so it is shown rather than hidden.
|
||||
setMessage(error.message);
|
||||
return;
|
||||
}
|
||||
@@ -54,45 +80,116 @@ export function SignIn({ config }: { config: PublicConfig }) {
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
{status === 'sent' ? (
|
||||
<div className="space-y-2 text-center">
|
||||
<p className="font-medium">Check your email</p>
|
||||
<p className="text-sm text-muted">
|
||||
A sign-in link is on its way to {email}. It expires shortly, so use it soon.
|
||||
</p>
|
||||
<div className="space-y-3 text-center">
|
||||
<Mail className="mx-auto h-8 w-8 text-accent-fg" aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium">Check your email</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
A sign-in link is on its way to {email}. It expires shortly, so use it
|
||||
soon.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('idle')}
|
||||
className="tap text-sm font-medium text-accent-fg"
|
||||
>
|
||||
Use a different address
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">Email</span>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoComplete="email"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={status === 'sending'}
|
||||
<>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Sign-in method"
|
||||
className="mb-4 inline-flex w-full rounded-lg bg-surface-2 p-1"
|
||||
>
|
||||
{status === 'sending' ? 'Sending…' : 'Email me a sign-in link'}
|
||||
</Button>
|
||||
{status === 'error' ? (
|
||||
<p className="text-sm text-danger">{message}</p>
|
||||
) : null}
|
||||
{config.inviteRequired ? (
|
||||
<p className="text-center text-xs text-muted">
|
||||
PIG is invite-only. An account alone does not grant access.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
{(
|
||||
[
|
||||
{ key: 'password', label: 'Password', icon: KeyRound },
|
||||
{ key: 'link', label: 'Email link', icon: Mail },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={method === option.key}
|
||||
onClick={() => {
|
||||
setMethod(option.key);
|
||||
setStatus('idle');
|
||||
setMessage('');
|
||||
}}
|
||||
className={[
|
||||
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors',
|
||||
method === option.key ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
<option.icon className="h-4 w-4" aria-hidden />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">Email</span>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoComplete="username"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{method === 'password' ? (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">Password</span>
|
||||
<Input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
// `current-password` is what lets a password manager
|
||||
// offer to fill, and iOS to offer a saved credential.
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={status === 'busy'}
|
||||
>
|
||||
{status === 'busy'
|
||||
? method === 'password'
|
||||
? 'Signing in…'
|
||||
: 'Sending…'
|
||||
: method === 'password'
|
||||
? 'Sign in'
|
||||
: 'Email me a sign-in link'}
|
||||
</Button>
|
||||
|
||||
{status === 'error' ? (
|
||||
<p className="text-sm text-danger" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{config.inviteRequired ? (
|
||||
<p className="pt-1 text-center text-xs text-muted">
|
||||
PIG is invite-only. An account alone does not grant access.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user