Merge login-redesign: login page showing what the app can do
Build and Push Docker Image / build-and-push (push) Failing after 19s
Build and Push Docker Image / build-and-push (push) Failing after 19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bb80623c45
2 files changed
+234
-77
No files matched your search
@@ -391,3 +391,53 @@ describe('Window title', () => {
|
||||
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login page', () => {
|
||||
async function openLogin() {
|
||||
stubServer({ user: null });
|
||||
render(<App />);
|
||||
return screen.findByRole('button', { name: /^sign in$/i });
|
||||
}
|
||||
|
||||
test('tells you what the app can do, in groups', async () => {
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { level: 1, name: /read it\. study it\./i })).toBeInTheDocument();
|
||||
for (const group of ['Read', 'Study', 'Teach', 'Everywhere']) expect(screen.getByRole('region', { name: group })).toBeInTheDocument();
|
||||
expect(screen.getByText('A real outline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Compare them')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('switches between signing in and creating an account', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /need an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^create account$/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /already have an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a theme can be picked before signing in', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(localStorage.getItem('reader-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
test('a wrong password shows the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
if (url === '/api/auth/me') return json(401, {});
|
||||
if (url === '/api/auth/login') return json(401, { error: 'Incorrect email or password.' });
|
||||
return Promise.resolve({ ok: false });
|
||||
}));
|
||||
render(<App />);
|
||||
await user.type(await screen.findByLabelText(/email/i), 'a@b.co');
|
||||
await user.type(screen.getByLabelText(/^password/i), 'wrong-password');
|
||||
await user.click(screen.getByRole('button', { name: /^sign in$/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/incorrect email or password/i);
|
||||
});
|
||||
});
|
||||
+184
-77
@@ -1,8 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { themeVars } from '../components/reader/readerThemes.js';
|
||||
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
|
||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||
|
||||
// What the app does, in four groups. Everything listed here exists in the app.
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Read',
|
||||
points: [
|
||||
['Six translations', 'BSB, ESV, NLT, CSB, NASB and NET, with audio and whole-Bible search.'],
|
||||
['Compare them', 'Read two side by side and see which words differ, verse by verse.'],
|
||||
['Mark it up', 'Highlight in colours you name, bookmark, and draw notes on the page.'],
|
||||
['Reading plans', 'Daily readings that open straight into the text, with streaks and reminders.'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Study',
|
||||
points: [
|
||||
['Chunk a passage', 'Break it into sections and write Observe, Interpret and Apply notes.'],
|
||||
['Original languages', 'Greek and Hebrew word studies with pronunciation, from the interlinear.'],
|
||||
['Beside the text', 'Commentary, cross-references, and people and places for each chunk.'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Teach',
|
||||
points: [
|
||||
['A real outline', 'Main points and sub-points for the whole study and for each chunk, with notes, verse text and minutes.'],
|
||||
['Scripts', 'Turn your notes into a script for a lesson, episode or sermon.'],
|
||||
['Share it', 'Word, PDF and Markdown files, or a read-only link anyone can open.'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Everywhere',
|
||||
points: [
|
||||
['Your devices', 'Studies, marks and plans sync to your account, and nothing is overwritten silently.'],
|
||||
['Offline and backup', 'Save books to read without a connection, and download everything as one file.'],
|
||||
['Private', 'Your studies stay in your account, with optional two-factor sign-in.'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Small, decorative pictures of two real views, so you can see what you're signing in to.
|
||||
function OutlinePreview() {
|
||||
const row = (label, text, depth = 0, tag = '') => (
|
||||
<div className="flex items-baseline gap-2" style={{ paddingLeft: `${depth * 1.1}rem` }}>
|
||||
<span className="w-5 shrink-0 text-right font-semibold text-[var(--r-accent)]">{label}</span>
|
||||
<span className={depth === 0 ? 'font-semibold' : ''}>
|
||||
{tag && <span className="mr-1.5 rounded-full border border-[var(--r-line)] px-1.5 py-px font-sans text-[8px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{tag}</span>}
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div aria-hidden="true" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
|
||||
<p className="mb-2 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
|
||||
{row('I.', 'Greeting and the gospel hope')}
|
||||
{row('A.', 'Servant of God', 1, 'Illustration')}
|
||||
{row('B.', 'Apostle of Jesus Christ', 1)}
|
||||
{row('II.', 'Appoint elders in every town')}
|
||||
{row('A.', 'Blameless and faithful', 1, 'Application')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparePreview() {
|
||||
const mark = (t) => <span className="diff-hl">{t}</span>;
|
||||
return (
|
||||
<div aria-hidden="true" className="grid grid-cols-2 gap-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
|
||||
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">BSB</p>The earth was {mark('formless and void')}, and darkness was over the surface of the deep.</div>
|
||||
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">NET</p>The earth was {mark('without shape and empty')}, and darkness was over the surface of the deep.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwitch() {
|
||||
const { readerTheme, setReaderTheme } = useApp();
|
||||
if (!setReaderTheme) return null;
|
||||
return (
|
||||
<div role="group" aria-label="Theme" className="flex overflow-hidden rounded-full border border-[var(--r-line)] text-xs">
|
||||
{Object.entries(READER_THEMES).map(([id, t]) => (
|
||||
<button key={id} type="button" aria-pressed={readerTheme === id} onClick={() => setReaderTheme(id)}
|
||||
className={`px-3 py-1.5 ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthPage() {
|
||||
const { signIn, readerTheme } = useApp();
|
||||
const shell = { style: themeVars(readerTheme) };
|
||||
@@ -53,8 +136,8 @@ export default function AuthPage() {
|
||||
return (
|
||||
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 text-[var(--r-text)]">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||
<p className="flex items-center gap-2 text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]"><img src="/logo.svg" alt="" className="h-6 w-6" />Bible Study Project</p>
|
||||
<h1 className="mt-3 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">
|
||||
{authMfaUseBackup
|
||||
? 'Enter one of your saved backup codes.'
|
||||
@@ -109,83 +192,107 @@ export default function AuthPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 py-12 text-[var(--r-text)]">
|
||||
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
|
||||
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold leading-tight text-[var(--r-text)]">
|
||||
Deep Bible study,<br />one chunk at a time.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-sm text-[var(--r-sub)]">
|
||||
Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
|
||||
at a time with commentary, word studies, and cross-references beside it.
|
||||
</p>
|
||||
<ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]">
|
||||
<li>Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search</li>
|
||||
<li>Reading plans that open straight into the Reader</li>
|
||||
<li>Observe, Interpret, Apply notes with Greek & Hebrew word studies</li>
|
||||
<li>Turn your notes into a script, then export or share</li>
|
||||
<li>Your studies stay private to your account, with optional 2FA</li>
|
||||
</ul>
|
||||
<blockquote className="mt-8 border-l-2 border-[var(--r-line)] pl-4 text-sm italic text-[var(--r-sub)]">
|
||||
"Make every effort to present yourself approved to God, an unashamed workman who accurately
|
||||
handles the word of truth."
|
||||
<footer className="mt-1 not-italic text-[var(--r-sub)]">— 2 Timothy 2:15 (BSB)</footer>
|
||||
</blockquote>
|
||||
<div {...shell} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 pt-5 sm:px-8">
|
||||
<p className="flex items-center gap-2.5 text-sm font-medium">
|
||||
<img src="/logo.svg" alt="" className="h-7 w-7" />
|
||||
<span>Bible Study Project</span>
|
||||
</p>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid max-w-6xl gap-x-14 gap-y-12 px-5 pb-16 pt-8 sm:px-8 lg:grid-cols-[minmax(0,1fr)_24rem] lg:pt-14">
|
||||
{/* Sign in / create account. First on a phone; on a wide screen it stays in view beside the tour. */}
|
||||
<div className="lg:order-2">
|
||||
<div className="lg:sticky lg:top-10">
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-7 shadow-sm sm:p-8">
|
||||
<h2 className="text-xl font-medium">{authMode === 'login' ? 'Sign in' : 'Create an account'}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">
|
||||
{authMode === 'login' ? 'Pick up where you left off.' : 'Free to start. Your studies stay in your account.'}
|
||||
</p>
|
||||
<form onSubmit={submitAuth} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={authForm.email}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
value={authForm.password}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
|
||||
/>
|
||||
{authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>}
|
||||
</label>
|
||||
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
||||
setAuthError('');
|
||||
}}
|
||||
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
|
||||
>
|
||||
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
<blockquote className="mt-6 hidden border-l-2 border-[var(--r-line)] pl-4 text-sm italic leading-relaxed text-[var(--r-sub)] lg:block">
|
||||
“Make every effort to present yourself approved to God, an unashamed workman who accurately handles the word of truth.”
|
||||
<footer className="mt-1 not-italic">— 2 Timothy 2:15 (BSB)</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign in / register form */}
|
||||
<div className="flex justify-center lg:justify-start">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)] lg:hidden">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">
|
||||
{authMode === 'login' ? 'Sign in' : 'Create an account'}
|
||||
{/* What the app does */}
|
||||
<div className="lg:order-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible study, start to finish</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold leading-[1.1] tracking-tight sm:text-5xl">
|
||||
Read it. Study it.<br />Teach it.
|
||||
</h1>
|
||||
<form onSubmit={submitAuth} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={authForm.email}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-[var(--r-text)]">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
value={authForm.password}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
||||
setAuthError('');
|
||||
}}
|
||||
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
|
||||
>
|
||||
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-[var(--r-sub)]">
|
||||
A place to read Scripture in six translations, work through a passage one chunk at a time, and turn what you learn into an outline,
|
||||
a script, and something you can share.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<OutlinePreview />
|
||||
<ComparePreview />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-x-10 gap-y-9 sm:grid-cols-2">
|
||||
{FEATURES.map((group) => (
|
||||
<section key={group.title} aria-label={group.title}>
|
||||
<h2 className="border-b border-[var(--r-line)] pb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">{group.title}</h2>
|
||||
<ul className="mt-3 space-y-3.5">
|
||||
{group.points.map(([name, text]) => (
|
||||
<li key={name}>
|
||||
<p className="text-[15px] font-medium">{name}</p>
|
||||
<p className="mt-0.5 text-sm leading-relaxed text-[var(--r-sub)]">{text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user