Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

7.9 kB Ruw bestand
import { useEffect, useState, type ReactNode } from 'react';
import { api, ApiError } from '../api/client';
import { useI18n } from '../i18n';
import { useSession } from '../state/session';
import { goTo, workspacePath } from '../state/route';

/**
 * The three screens for someone who cannot get in, or has just proved they can
 * be reached. They share one card, so they live in one file: asking for a link,
 * spending it, and confirming an address are one flow with three stops.
 */
function AuthCard({ tagline, children }: { tagline: string; children: ReactNode }): ReactNode {
  return (
    <div className="login">
      <div className="login-card">
        <div className="wordmark">
          Well<span>formed</span>
        </div>
        <div className="tagline">{tagline}</div>
        {children}
      </div>
    </div>
  );
}

/**
 * Ask for a reset link.
 *
 * The server answers the same way for an address it knows and one it does not,
 * so this screen must too — anything else would turn the form into a way to
 * ask "does this person have an account here?".
 */
export function ForgotScreen(): ReactNode {
  const { t } = useI18n();
  const [email, setEmail] = useState('');
  const [sent, setSent] = useState<'email' | 'none' | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  async function submit(event: React.FormEvent) {
    event.preventDefault();
    setBusy(true);
    setError(null);
    try {
      const result = await api.forgotPassword(email.trim());
      setSent(result.delivery);
    } catch (err) {
      setError(err instanceof Error ? err.message : t('auth.forgotFailed'));
    } finally {
      setBusy(false);
    }
  }

  if (sent === 'email') {
    return (
      <AuthCard tagline={t('auth.checkEmailTagline')}>
        <p className="hint">
          {t('auth.resetSentBefore')}
          <span className="mono">{email.trim()}</span>
          {t('auth.resetSentAfter')}
        </p>
        <p className="hint">
          <a href="/signin">{t('auth.backToSignIn')}</a>
        </p>
      </AuthCard>
    );
  }

  // No mail transport configured. Saying so beats a "check your inbox" that
  // will never arrive — this install's operator has to hand the link over.
  if (sent === 'none') {
    return (
      <AuthCard tagline={t('auth.askOperatorTagline')}>
        <p className="hint">
          {t('auth.noMailBefore')}
          <span className="mono">npm run recover -- --email {email.trim()}</span>.
        </p>
        <p className="hint">
          <a href="/signin">{t('auth.backToSignIn')}</a>
        </p>
      </AuthCard>
    );
  }

  return (
    <AuthCard tagline={t('auth.forgotTagline')}>
      <form onSubmit={(e) => void submit(e)}>
        {error && (
          <div className="formerror" role="alert">
            {error}
          </div>
        )}

        <label>
          {t('common.email')}
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            autoComplete="username"
            required
            autoFocus
          />
        </label>

        <button className="btn primary" type="submit" disabled={busy || !email.trim()}>
          {busy ? t('common.sending') : t('auth.sendResetLink')}
        </button>
      </form>

      <p className="hint">
        {t('auth.rememberedBefore')}
        <a href="/signin">{t('auth.signInLink')}</a>.
      </p>
    </AuthCard>
  );
}

/** Spend a reset link: choose a new password, and land signed in. */
export function ResetScreen({ token }: { token: string }): ReactNode {
  const session = useSession();
  const { t } = useI18n();
  const [target, setTarget] = useState<{ email: string; name: string } | null>(null);
  const [dead, setDead] = useState<{ message: string | null } | null>(null);
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    let live = true;
    api
      .readReset(token)
      .then((info) => {
        if (live) setTarget({ email: info.email, name: info.name });
      })
      .catch((err: unknown) => {
        if (!live) return;
        setDead({ message: err instanceof ApiError ? err.message : null });
      });
    return () => {
      live = false;
    };
  }, [token]);

  async function submit(event: React.FormEvent) {
    event.preventDefault();
    setBusy(true);
    setError(null);
    try {
      const next = await api.resetPassword(token, password);
      // The reset ended every other session, including any this browser held,
      // and issued a fresh one. Reload through the router so the app boots
      // from the new session rather than the stale cache.
      const only = next.workspaces.length === 1 ? next.workspaces[0] : undefined;
      goTo(only ? workspacePath(only.slug) : '/');
    } catch (err) {
      setError(err instanceof Error ? err.message : t('auth.setPasswordFailed'));
      setBusy(false);
    }
  }

  if (dead) {
    return (
      <AuthCard tagline={t('auth.linkSpentTagline')}>
        <p className="hint">{dead.message ?? t('auth.resetLinkDead')}</p>
        <p className="hint">
          <a href="/forgot">{t('auth.askNewLink')}</a>.
        </p>
      </AuthCard>
    );
  }

  if (!target) return <AuthCard tagline={t('auth.checkingLink')}>{null}</AuthCard>;

  return (
    <AuthCard tagline={t('auth.newPasswordFor', target.email)}>
      <form onSubmit={(e) => void submit(e)}>
        {error && (
          <div className="formerror" role="alert">
            {error}
          </div>
        )}

        <label>
          {t('account.newPassword')}
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            autoComplete="new-password"
            required
            autoFocus
          />
          {password.length > 0 && password.length < 10 && (
            <span className="hint bad">{t('account.tooShort')}</span>
          )}
        </label>

        <button className="btn primary" type="submit" disabled={busy || password.length < 10}>
          {busy ? t('auth.settingPassword') : t('auth.setAndSignIn')}
        </button>
      </form>

      <p className="hint">{t('auth.resetEndsSessions')}</p>
      {session.account && session.account.email !== target.email && (
        <p className="hint bad">{t('auth.signedInAsOther', session.account.email, target.email)}</p>
      )}
    </AuthCard>
  );
}

/** Spend a confirmation link. Works signed out — mail is often read elsewhere. */
export function VerifyScreen({ token }: { token: string }): ReactNode {
  const session = useSession();
  const { t } = useI18n();
  const [state, setState] = useState<
    { done: true; email: string } | { done: false; message: string | null } | null
  >(null);

  useEffect(() => {
    let live = true;
    api
      .confirmEmail(token)
      .then(async (result) => {
        if (!live) return;
        setState({ done: true, email: result.email });
        // The signed-in copy of the account still says unverified.
        if (session.account) await session.refresh();
      })
      .catch((err: unknown) => {
        if (!live) return;
        setState({ done: false, message: err instanceof ApiError ? err.message : null });
      });
    return () => {
      live = false;
    };
    // Runs once per token; `session` changes identity on every refresh.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [token]);

  if (!state) return <AuthCard tagline={t('auth.confirming')}>{null}</AuthCard>;

  return (
    <AuthCard tagline={state.done ? t('auth.addressConfirmedTagline') : t('auth.linkSpentTagline')}>
      <p className="hint">
        {state.done
          ? t('auth.emailConfirmed', state.email)
          : (state.message ?? t('auth.confirmLinkDead'))}
      </p>
      <p className="hint">
        <a href="/">{session.account ? t('auth.backToWorkspaces') : t('auth.signIn')}</a>
      </p>
    </AuthCard>
  );
}