feat: step-by-step sign-in with server discovery from the email address

This commit is contained in:
Linus Rath
2026-08-01 13:28:31 +02:00
parent 82997e27e4
commit c29cbcd549
16 changed files with 2744 additions and 276 deletions
+953
View File
@@ -0,0 +1,953 @@
<title>Bulwark Mobile — Sign-in redesign</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" /><path d="M14 14h3v3h-3z" /><path d="M21 14v3M21 21h-4" />
</symbol>
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" /><path d="m2 7 10 6 10-6" />
</symbol>
<symbol id="i-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="10" width="16" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" />
</symbol>
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 12 5 5L20 6" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" /><path d="M12 7v6M12 16.5v.5" />
</symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 6 18 18M18 6 6 18" />
</symbol>
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z" /><circle cx="12" cy="12" r="2.6" />
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3 5 6v6c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12h14" />
</symbol>
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7.5" cy="15.5" r="4.5" /><path d="m10.8 12.2 8-8M17 6l2.5 2.5M14.5 8.5 17 11" />
</symbol>
</defs>
</svg>
<style>
:root {
--page-bg: #fbfbfc;
--page-panel: #f0f3f8;
--page-ink: #14171f;
--page-ink-2: #545c6b;
--page-ink-3: #8a919f;
--page-rule: #e1e6ef;
--accent: #2f6fed;
--accent-soft: rgba(47, 111, 237, 0.09);
--app-bg: #ffffff;
--app-surface: #f8fafc;
--app-surface-2: #f1f5f9;
--app-border: #e2e8f0;
--app-text: #0f172a;
--app-text-2: #64748b;
--app-muted: #94a3b8;
--app-primary: #3b82f6;
--app-primary-bg: #dbeafe;
--app-error: #ef4444;
--app-error-bg: #fee2e2;
--app-success: #16a34a;
--frame: #d5dae5;
--frame-shadow: rgba(20, 26, 40, 0.14);
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
--sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #0a0b0d;
--page-panel: #111319;
--page-ink: #eef1f6;
--page-ink-2: #a4adbb;
--page-ink-3: #6e7684;
--page-rule: #22262f;
--accent: #74aaff;
--accent-soft: rgba(116, 170, 255, 0.12);
--app-bg: #09090b;
--app-surface: #18181b;
--app-surface-2: #1f1f23;
--app-border: #27272a;
--app-text: #fafafa;
--app-text-2: #a1a1aa;
--app-muted: #71717a;
--app-primary: #3b82f6;
--app-primary-bg: #172554;
--app-error: #ef4444;
--app-error-bg: #450a0a;
--app-success: #22c55e;
--frame: #2b303a;
--frame-shadow: rgba(0, 0, 0, 0.55);
}
}
:root[data-theme="light"] {
--page-bg: #fbfbfc; --page-panel: #f0f3f8; --page-ink: #14171f; --page-ink-2: #545c6b;
--page-ink-3: #8a919f; --page-rule: #e1e6ef; --accent: #2f6fed; --accent-soft: rgba(47,111,237,0.09);
--app-bg: #ffffff; --app-surface: #f8fafc; --app-surface-2: #f1f5f9; --app-border: #e2e8f0;
--app-text: #0f172a; --app-text-2: #64748b; --app-muted: #94a3b8; --app-primary: #3b82f6;
--app-primary-bg: #dbeafe; --app-error: #ef4444; --app-error-bg: #fee2e2; --app-success: #16a34a;
--frame: #d5dae5; --frame-shadow: rgba(20,26,40,0.14);
}
:root[data-theme="dark"] {
--page-bg: #0a0b0d; --page-panel: #111319; --page-ink: #eef1f6; --page-ink-2: #a4adbb;
--page-ink-3: #6e7684; --page-rule: #22262f; --accent: #74aaff; --accent-soft: rgba(116,170,255,0.12);
--app-bg: #09090b; --app-surface: #18181b; --app-surface-2: #1f1f23; --app-border: #27272a;
--app-text: #fafafa; --app-text-2: #a1a1aa; --app-muted: #71717a; --app-primary: #3b82f6;
--app-primary-bg: #172554; --app-error: #ef4444; --app-error-bg: #450a0a; --app-success: #22c55e;
--frame: #2b303a; --frame-shadow: rgba(0,0,0,0.55);
}
body {
background: var(--page-bg);
color: var(--page-ink);
font-family: var(--serif);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.d-wrap { max-width: 1180px; margin: 0 auto; padding: 64px 28px 96px; }
/* ---------- document typography ---------- */
.d-eyebrow {
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--page-ink-3); margin: 0 0 18px;
}
.d-title {
font-family: var(--sans); font-weight: 700; font-size: clamp(34px, 5vw, 52px);
line-height: 1.04; letter-spacing: -0.028em; margin: 0; text-wrap: balance;
}
.d-deck {
max-width: 62ch; margin: 22px 0 0; font-size: 19px; line-height: 1.62; color: var(--page-ink-2);
}
.d-deck strong { color: var(--page-ink); font-weight: 600; }
.d-section { margin-top: 84px; }
.d-h2 {
font-family: var(--sans); font-weight: 700; font-size: 25px; letter-spacing: -0.018em;
margin: 0; text-wrap: balance;
}
.d-lede { max-width: 62ch; margin: 12px 0 0; color: var(--page-ink-2); }
.d-rule { height: 1px; background: var(--page-rule); border: 0; margin: 26px 0 34px; }
.d-meta {
display: flex; flex-wrap: wrap; gap: 8px 10px; margin-top: 30px;
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-2);
}
.d-meta span {
border: 1px solid var(--page-rule); border-radius: 999px; padding: 4px 11px; background: var(--page-panel);
}
/* ---------- comparison ---------- */
.d-compare {
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px;
background: var(--page-rule); border: 1px solid var(--page-rule); border-radius: 12px; overflow: hidden;
}
.d-cell { background: var(--page-bg); padding: 20px 22px; }
.d-cell dt {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--page-ink-3); margin: 0 0 10px;
}
.d-cell dd { margin: 0; font-family: var(--sans); font-size: 15px; line-height: 1.45; }
.d-was { color: var(--page-ink-3); text-decoration: line-through; text-decoration-thickness: 1px; }
.d-now { color: var(--page-ink); font-weight: 600; display: block; margin-top: 3px; }
/* ---------- filmstrip ---------- */
.d-strip {
display: flex; gap: 30px; overflow-x: auto; padding: 30px 26px 26px;
background: var(--page-panel); border-radius: 16px; scroll-snap-type: x proximity;
}
.d-strip figure { margin: 0; scroll-snap-align: center; flex: 0 0 auto; width: 288px; }
.d-strip figcaption { margin-top: 18px; }
.d-step {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); display: block; margin-bottom: 5px;
}
.d-step.is-alt { color: var(--page-ink-3); }
.d-cap-title { font-family: var(--sans); font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
.d-cap-body { font-size: 14.5px; line-height: 1.5; color: var(--page-ink-2); margin: 5px 0 0; }
/* ---------- notes ---------- */
.d-notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 22px; max-width: 74ch; }
.d-notes li { display: grid; grid-template-columns: 26px 1fr; gap: 14px; align-items: start; }
.d-notes svg { width: 18px; height: 18px; color: var(--accent); margin-top: 4px; }
.d-note-h { font-family: var(--sans); font-weight: 650; font-size: 15.5px; }
.d-note-b { color: var(--page-ink-2); font-size: 16px; }
code {
font-family: var(--mono); font-size: 0.85em; background: var(--accent-soft);
color: var(--accent); padding: 1.5px 5px; border-radius: 4px;
}
.d-foot {
margin-top: 80px; padding-top: 22px; border-top: 1px solid var(--page-rule);
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-3);
}
/* ================= phone chrome ================= */
.p {
width: 288px; height: 600px; border-radius: 36px; background: var(--frame);
padding: 8px; box-shadow: 0 18px 44px -18px var(--frame-shadow); position: relative;
}
.p-screen {
width: 100%; height: 100%; border-radius: 29px; background: var(--app-bg); overflow: hidden;
position: relative; font-family: var(--sans); color: var(--app-text); font-size: 14px; line-height: 1.4;
display: flex; flex-direction: column;
}
.p-notch {
position: absolute; top: 15px; left: 50%; transform: translateX(-50%); width: 74px; height: 5px;
border-radius: 99px; background: var(--app-border); z-index: 6;
}
.p-pad { padding: 42px 24px 22px; flex: 1; display: flex; flex-direction: column; }
.p-pad.is-centered { justify-content: center; }
/* ---- in-app primitives ---- */
.s-logo {
width: 56px; height: 56px; border-radius: 17px; background: var(--app-primary);
display: grid; place-items: center; margin: 0 auto 16px; color: #fff;
}
.s-logo svg { width: 30px; height: 30px; }
.s-brand { text-align: center; margin-bottom: 30px; }
.s-brand h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
.s-brand p { font-size: 12.5px; color: var(--app-muted); margin: 5px 0 0; }
.s-h { font-size: 21px; font-weight: 700; letter-spacing: -0.022em; margin: 0; line-height: 1.22; }
.s-sub { font-size: 13px; color: var(--app-text-2); margin: 8px 0 0; line-height: 1.45; }
.s-tile {
display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
border: 1px solid var(--app-border); background: var(--app-surface);
border-radius: 14px; padding: 14px 15px; color: inherit;
}
.s-tile.is-primary { background: var(--app-primary); border-color: var(--app-primary); color: #fff; }
.s-tile-ico { width: 22px; height: 22px; flex: 0 0 22px; opacity: 0.95; }
.s-tile-txt { flex: 1; min-width: 0; }
.s-tile-t { font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; }
.s-tile-d { font-size: 11.5px; opacity: 0.72; margin-top: 2px; }
.s-tile-chev { width: 16px; height: 16px; opacity: 0.5; }
.s-stack { display: flex; flex-direction: column; gap: 10px; }
.s-field { margin-top: 20px; }
.s-label { font-size: 12px; font-weight: 600; color: var(--app-text-2); margin-bottom: 7px; }
.s-input {
border: 1px solid var(--app-border); background: var(--app-bg); border-radius: 10px;
height: 42px; padding: 0 13px; display: flex; align-items: center; gap: 8px; font-size: 14px;
}
.s-input.is-focus { border-color: var(--app-primary); box-shadow: 0 0 0 3px var(--app-primary-bg); }
.s-input.is-error { border-color: var(--app-error); }
.s-input .s-ph { color: var(--app-muted); }
.s-input svg { width: 17px; height: 17px; color: var(--app-muted); flex: 0 0 17px; }
.s-caret {
width: 1.5px; height: 17px; background: var(--app-primary); display: inline-block;
animation: blink 1.05s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .s-caret { animation: none; } }
.s-hint { font-size: 11.5px; color: var(--app-muted); margin-top: 8px; line-height: 1.45; }
.s-hint.is-error { color: var(--app-error); }
.s-btn {
height: 46px; border-radius: 12px; background: var(--app-primary); color: #fff;
display: grid; place-items: center; font-size: 15px; font-weight: 600; margin-top: 20px;
}
.s-btn.is-off { background: var(--app-surface-2); color: var(--app-muted); }
.s-btn.is-quiet {
background: transparent; color: var(--app-text); border: 1px solid var(--app-border); font-weight: 500;
}
.s-link { text-align: center; font-size: 13px; color: var(--app-primary); margin-top: 16px; font-weight: 500; }
.s-link.is-muted { color: var(--app-muted); font-weight: 400; }
.s-back { width: 22px; height: 22px; color: var(--app-text-2); margin-bottom: 22px; }
.s-foot {
text-align: center; font-size: 11px; color: var(--app-muted); padding: 0 0 8px; line-height: 1.6;
}
.s-card {
border: 1px solid var(--app-border); background: var(--app-surface); border-radius: 14px;
padding: 15px; display: flex; align-items: center; gap: 12px; margin-top: 22px;
}
.s-card-ico {
width: 38px; height: 38px; border-radius: 11px; background: var(--app-primary-bg);
color: var(--app-primary); display: grid; place-items: center; flex: 0 0 38px;
}
.s-card-ico svg { width: 19px; height: 19px; }
.s-card-t { font-size: 14.5px; font-weight: 600; }
.s-card-d { font-size: 11.5px; color: var(--app-text-2); margin-top: 2px; display: flex; align-items: center; gap: 5px; }
.s-dot { width: 5px; height: 5px; border-radius: 99px; background: var(--app-success); display: inline-block; }
.s-note {
display: flex; gap: 9px; align-items: flex-start; margin-top: 16px;
font-size: 11.5px; color: var(--app-text-2); line-height: 1.5;
}
.s-note svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-muted); }
.s-banner {
display: flex; gap: 9px; align-items: flex-start; border-radius: 11px; padding: 11px 12px;
background: var(--app-error-bg); font-size: 12px; line-height: 1.45; margin-top: 18px;
}
.s-banner svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-error); }
.s-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; }
.s-divider::before, .s-divider::after { content: ""; height: 1px; flex: 1; background: var(--app-border); }
.s-divider span { font-size: 11.5px; color: var(--app-muted); }
/* progress rows (setting up) */
.s-prog { display: flex; align-items: center; gap: 11px; padding: 11px 0; font-size: 13.5px; }
.s-prog .s-tick {
width: 20px; height: 20px; border-radius: 99px; background: var(--app-success);
color: #fff; display: grid; place-items: center; flex: 0 0 20px;
}
.s-prog .s-tick svg { width: 11px; height: 11px; }
.s-prog .s-spin {
width: 20px; height: 20px; border-radius: 99px; flex: 0 0 20px;
border: 2px solid var(--app-border); border-top-color: var(--app-primary); animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .s-prog .s-spin { animation: none; } }
.s-prog.is-pending { color: var(--app-muted); }
/* account rows (add mode) */
.s-acct { display: flex; align-items: center; gap: 11px; padding: 11px 0; }
.s-av {
width: 34px; height: 34px; border-radius: 99px; display: grid; place-items: center;
font-size: 13px; font-weight: 600; color: #fff; flex: 0 0 34px;
}
.s-acct-t { font-size: 14px; font-weight: 550; }
.s-acct-d { font-size: 11.5px; color: var(--app-muted); }
/* scanner — deliberately always dark, it's a camera view */
.p-screen.is-camera { background: #0b0d10; color: #fff; }
.s-cam { position: absolute; inset: 0; background:
radial-gradient(120% 80% at 50% 30%, #2a3038 0%, #14171c 55%, #0b0d10 100%); }
.s-cam-grid { position: absolute; inset: 0; opacity: 0.18; background-image:
linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px); background-size: 34px 34px; }
.s-reticle { position: absolute; top: 168px; left: 50%; transform: translateX(-50%); width: 176px; height: 176px; }
.s-reticle i {
position: absolute; width: 34px; height: 34px; border: 3px solid #fff; border-radius: 4px;
}
.s-reticle i:nth-child(1) { top: 0; left: 0; border-right: 0; border-bottom: 0; }
.s-reticle i:nth-child(2) { top: 0; right: 0; border-left: 0; border-bottom: 0; }
.s-reticle i:nth-child(3) { bottom: 0; left: 0; border-right: 0; border-top: 0; }
.s-reticle i:nth-child(4) { bottom: 0; right: 0; border-left: 0; border-top: 0; }
.s-qr {
position: absolute; inset: 22px; border-radius: 6px; background: #fff;
display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(9, 1fr); padding: 8px; gap: 2px;
}
.s-qr b { background: #0b0d10; border-radius: 1px; }
.s-cam-top {
position: absolute; top: 40px; left: 0; right: 0; display: flex; align-items: center;
justify-content: space-between; padding: 0 20px; z-index: 3;
}
.s-cam-top svg { width: 22px; height: 22px; color: #fff; }
.s-cam-copy { position: absolute; left: 0; right: 0; bottom: 46px; text-align: center; padding: 0 30px; z-index: 3; }
.s-cam-copy p { margin: 0; font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.82); }
.s-cam-copy .s-cam-h { font-size: 15.5px; font-weight: 600; color: #fff; margin-bottom: 6px; }
.s-cam-copy .s-cam-link { margin-top: 18px; font-size: 13px; font-weight: 500; color: #8ab4ff; }
/* browser handoff sheet */
.s-sheet {
position: absolute; inset: 26px 0 0; background: var(--app-bg); border-radius: 18px 18px 0 0;
box-shadow: 0 -8px 40px rgba(0,0,0,0.35); display: flex; flex-direction: column; overflow: hidden;
}
.s-sheet-bar {
display: flex; align-items: center; gap: 9px; padding: 13px 15px;
border-bottom: 1px solid var(--app-border); font-size: 12.5px; color: var(--app-text-2);
}
.s-sheet-bar svg { width: 13px; height: 13px; color: var(--app-success); }
.s-sheet-bar .s-url { flex: 1; color: var(--app-text); font-weight: 550; }
.s-sheet-bar .s-done { color: var(--app-primary); font-weight: 600; }
.s-sheet-body { padding: 30px 24px; flex: 1; }
.s-scrim { position: absolute; inset: 0; background: rgba(4,6,10,0.5); }
.s-web-brand { display: flex; align-items: center; gap: 9px; margin-bottom: 24px; }
.s-web-brand .s-web-mark {
width: 28px; height: 28px; border-radius: 9px; background: var(--app-primary);
color: #fff; display: grid; place-items: center;
}
.s-web-brand .s-web-mark svg { width: 16px; height: 16px; }
.s-web-brand b { font-size: 14.5px; letter-spacing: -0.01em; }
/* the "before" screen is intentionally cramped */
.s-old-field { margin-bottom: 13px; }
</style>
<div class="d-wrap">
<p class="d-eyebrow">Bulwark Mobile · sign-in redesign</p>
<h1 class="d-title">Three fields nobody knows the answer&nbsp;to</h1>
<p class="d-deck">
The current sign-in screen opens with a <strong>Server URL</strong> field. Most people don't know their
JMAP endpoint, and the two paths that don't require knowing it — the webmail handoff and the QR code — sit
below a divider, greyed out until the URL is filled in anyway. This proposes an entry screen that leads with
the easy paths and asks for a server only when we genuinely can't work it out.
</p>
<div class="d-meta">
<span>src/screens/LoginScreen.tsx</span>
<span>expo · react-native</span>
<span>light + dark, both shown</span>
</div>
<section class="d-section">
<h2 class="d-h2">What changes</h2>
<hr class="d-rule" />
<dl class="d-compare">
<div class="d-cell">
<dt>To reach the inbox</dt>
<dd><span class="d-was">3 fields typed</span><span class="d-now">1 field, or none with a code</span></dd>
</div>
<div class="d-cell">
<dt>Server URL</dt>
<dd><span class="d-was">Asked first, always</span><span class="d-now">Looked up from the address</span></dd>
</div>
<div class="d-cell">
<dt>Password in the app</dt>
<dd><span class="d-was">Default path</span><span class="d-now">Fallback only</span></dd>
</div>
<div class="d-cell">
<dt>QR sign-in</dt>
<dd><span class="d-was">Ghost button, disabled</span><span class="d-now">First option offered</span></dd>
</div>
</dl>
</section>
<section class="d-section">
<h2 class="d-h2">Today</h2>
<p class="d-lede">
Everything at once, in the least helpful order. The webmail handoff is the safer route — it never puts a
password into the app — but it's disabled until you've supplied the server, so it can't rescue anyone who's stuck.
</p>
<hr class="d-rule" />
<div class="d-strip">
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
<p>Secure. Private. Yours.</p>
</div>
<div class="s-old-field">
<div class="s-label">Server URL</div>
<div class="s-input"><span class="s-ph">https://mail.example.com</span></div>
</div>
<div class="s-old-field">
<div class="s-label">Email or Username</div>
<div class="s-input"><span class="s-ph">you@example.com</span></div>
</div>
<div class="s-old-field">
<div class="s-label">Password</div>
<div class="s-input">
<span class="s-ph" style="flex:1">Enter your password</span>
<svg><use href="#i-eye" /></svg>
</div>
</div>
<div class="s-btn is-off" style="margin-top:12px">Sign In</div>
<div class="s-divider"><span>or continue with</span></div>
<div class="s-stack">
<div class="s-btn is-quiet" style="margin-top:0;opacity:0.45">Sign in via webmail</div>
<div class="s-link is-muted" style="margin-top:2px;opacity:0.45">Scan QR code</div>
</div>
</div>
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Current build</span>
<span class="d-cap-title">One screen, six controls</span>
<p class="d-cap-body">
The two disabled controls at the bottom are the ones that would have helped. Nothing tells you where
to find a server URL, and nothing happens if you don't have one.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">Proposed flow</h2>
<p class="d-lede">
One question per screen, cheapest question first. The screens below are the happy path, left to right.
</p>
<hr class="d-rule" />
<div class="d-strip">
<!-- 1 · welcome -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
<p>Secure. Private. Yours.</p>
</div>
<div class="s-stack">
<div class="s-tile is-primary">
<svg class="s-tile-ico"><use href="#i-qr" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Scan a sign-in code</span>
<span class="s-tile-d">Already signed in on the web? Fastest way in.</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Use my email address</span>
<span class="s-tile-d">We'll find your mail server</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
</div>
<div class="s-link is-muted">Enter server details manually</div>
</div>
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
</div>
</div>
<figcaption>
<span class="d-step">Step 1</span>
<span class="d-cap-title">Pick a way in</span>
<p class="d-cap-body">
Two live options instead of six mixed ones. Each tile says what it costs you before you tap it.
Manual setup stays reachable in one tap — it just isn't the greeting.
</p>
</figcaption>
</figure>
<!-- 2 · email -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">What's your email address?</h2>
<p class="s-sub">That's usually all we need — we'll look up the mail server for you.</p>
<div class="s-field">
<div class="s-input is-focus">
<svg><use href="#i-mail" /></svg>
<span>ada@example.com</span><span class="s-caret"></span>
</div>
<p class="s-hint">Works with Bulwark, Stalwart, and any JMAP mail server.</p>
</div>
<div class="s-btn">Continue</div>
<div style="flex:1"></div>
<div class="s-link is-muted">I know my server address</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 2</span>
<span class="d-cap-title">One field, keyboard already up</span>
<p class="d-cap-body">
Autofocus with the email keyboard, autocorrect off. The button stays enabled and validates on tap,
so nobody is left staring at a dead grey button wondering which field is wrong.
</p>
</figcaption>
</figure>
<!-- 3 · found server -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Found your mail server</h2>
<p class="s-sub">Next you'll sign in on your provider's own page.</p>
<div class="s-card">
<span class="s-card-ico"><svg><use href="#i-globe" /></svg></span>
<span>
<span class="s-card-t">mail.example.com</span>
<span class="s-card-d"><i class="s-dot"></i> JMAP · secure connection</span>
</span>
</div>
<div class="s-note">
<svg><use href="#i-lock" /></svg>
<span>Your password is typed on your provider's page, never into this app. Bulwark stores a token it can revoke.</span>
</div>
<div class="s-btn">Continue</div>
<div class="s-link">Use a different server</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 3</span>
<span class="d-cap-title">Confirm before the browser opens</span>
<p class="d-cap-body">
A browser jump with no warning feels like a bug. Naming the server first — and saying why the
password isn't asked for here — turns the detour into a security feature.
</p>
</figcaption>
</figure>
<!-- 4 · handoff -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered" style="opacity:0.5">
<div class="s-brand">
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
<h1>Bulwark Mail</h1>
</div>
</div>
<div class="s-scrim"></div>
<div class="s-sheet">
<div class="s-sheet-bar">
<svg><use href="#i-lock" /></svg>
<span class="s-url">mail.example.com</span>
<span class="s-done">Done</span>
</div>
<div class="s-sheet-body">
<div class="s-web-brand">
<span class="s-web-mark"><svg><use href="#i-shield" /></svg></span>
<b>Sign in to Bulwark</b>
</div>
<div class="s-label">Email</div>
<div class="s-input"><span>ada@example.com</span></div>
<div class="s-label" style="margin-top:14px">Password</div>
<div class="s-input is-focus">
<span style="flex:1">••••••••••</span><svg><use href="#i-eye" /></svg>
</div>
<div class="s-btn">Sign in</div>
<div class="s-divider"><span>or</span></div>
<div class="s-tile" style="padding:11px 13px">
<svg class="s-tile-ico"><use href="#i-key" /></svg>
<span class="s-tile-txt"><span class="s-tile-t">Continue with passkey</span></span>
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 4</span>
<span class="d-cap-title">The provider's page, in a sheet</span>
<p class="d-cap-body">
This is the existing <code>runWebmailHandoff</code> flow, unchanged. It already supports whatever
the server offers — password, OAuth, passkeys, SSO — which the in-app form never will.
</p>
</figcaption>
</figure>
<!-- 5 · setting up -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad is-centered">
<div style="text-align:center;margin-bottom:26px">
<span class="s-av" style="width:60px;height:60px;font-size:22px;background:var(--app-primary);margin:0 auto 14px">AL</span>
<h2 class="s-h" style="font-size:20px">Welcome back, Ada</h2>
<p class="s-sub">ada@example.com</p>
</div>
<div>
<div class="s-prog">
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Mailboxes
</div>
<div class="s-prog">
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Recent mail
</div>
<div class="s-prog is-pending">
<span class="s-spin"></span> Contacts and calendars
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Step 5</span>
<span class="d-cap-title">Land on progress, not a spinner</span>
<p class="d-cap-body">
The first sync is the slowest moment in the app. Naming what's arriving makes the wait legible, and
the inbox opens as soon as mailboxes land — the rest fills in behind it.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">When it doesn't go smoothly</h2>
<p class="d-lede">
Self-hosted servers, typo'd passwords, and second accounts are the normal case for this audience, not the
exception. These four screens carry most of the redesign's weight.
</p>
<hr class="d-rule" />
<div class="d-strip">
<!-- scanner -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen is-camera">
<div class="s-cam"><div class="s-cam-grid"></div></div>
<div class="s-cam-top">
<svg><use href="#i-x" /></svg>
<span style="font-size:12.5px;opacity:0.8">Sign-in code</span>
<span style="width:22px"></span>
</div>
<div class="s-reticle">
<i></i><i></i><i></i><i></i>
<div class="s-qr">
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
<b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b><b></b>
<b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b>
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b>
<b></b><b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b>
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b>
</div>
</div>
<div class="s-cam-copy">
<p class="s-cam-h">Open Bulwark on your computer</p>
<p>Settings → Devices → Add phone shows a code. Point the camera at it.</p>
<p class="s-cam-link">Type the code instead</p>
</div>
</div>
</div>
<figcaption>
<span class="d-step">Path A</span>
<span class="d-cap-title">The scanner tells you where the code is</span>
<p class="d-cap-body">
A scanner with no instructions is a dead end for anyone who hasn't seen the webmail's Devices page.
Redeeming a paired code skips the browser entirely — see <code>redeemPairingCode</code>.
</p>
</figcaption>
</figure>
<!-- discovery failed -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Where does your mail live?</h2>
<p class="s-sub">We couldn't find a server for <b>example.org</b>. If you self-host, the address is the one you use for webmail.</p>
<div class="s-field">
<div class="s-label">Server address</div>
<div class="s-input is-focus">
<svg><use href="#i-globe" /></svg>
<span>mail.example.org</span><span class="s-caret"></span>
</div>
<p class="s-hint">Same address you type in the browser. We'll add <b>https://</b>.</p>
</div>
<div class="s-btn">Continue</div>
<div style="flex:1"></div>
<div class="s-note">
<svg><use href="#i-qr" /></svg>
<span>Not sure? Scan a sign-in code from the webmail instead — it carries the address with it.</span>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Path B</span>
<span class="d-cap-title">Ask for the server people know</span>
<p class="d-cap-body">
Not the JMAP session URL — the webmail address they type every day. The scheme is added for them, and
a trailing slash or full <code>/jmap</code> path is accepted and trimmed.
</p>
</figcaption>
</figure>
<!-- password fallback + error -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<svg class="s-back"><use href="#i-back" /></svg>
<h2 class="s-h">Sign in to mail.example.org</h2>
<p class="s-sub">This server doesn't support the browser sign-in, so we'll ask here.</p>
<div class="s-field" style="margin-top:18px">
<div class="s-label">Email or username</div>
<div class="s-input"><span>ada@example.org</span></div>
</div>
<div class="s-field" style="margin-top:14px">
<div class="s-label">Password</div>
<div class="s-input is-error">
<span style="flex:1">••••••••</span><svg><use href="#i-eye" /></svg>
</div>
</div>
<div class="s-banner">
<svg><use href="#i-alert" /></svg>
<span><b>That password didn't work.</b> If your account has two-factor sign-in, you'll need an app password from the webmail.</span>
</div>
<div class="s-btn">Sign in</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Path C</span>
<span class="d-cap-title">Errors that name the likely cause</span>
<p class="d-cap-body">
The store's raw error string is replaced with mapped copy per failure: wrong credentials, unreachable
host, bad certificate, 2FA-needs-an-app-password. Each says what to do next.
</p>
</figcaption>
</figure>
<!-- add account -->
<figure>
<div class="p">
<div class="p-notch"></div>
<div class="p-screen">
<div class="p-pad">
<div style="display:flex;justify-content:flex-end"><svg class="s-back" style="margin-bottom:14px"><use href="#i-x" /></svg></div>
<h2 class="s-h">Add another account</h2>
<p class="s-sub">You're signed in to these already.</p>
<div style="margin-top:16px">
<div class="s-acct">
<span class="s-av" style="background:#3b82f6">AL</span>
<span><span class="s-acct-t">Ada Lovelace</span><span class="s-acct-d">ada@example.com</span></span>
</div>
<div class="s-acct">
<span class="s-av" style="background:#a855f7">AL</span>
<span><span class="s-acct-t">Work</span><span class="s-acct-d">a.lovelace@analytical.co</span></span>
</div>
</div>
<div class="s-divider"><span>add</span></div>
<div class="s-stack">
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-plus" /></svg>
<span class="s-tile-txt">
<span class="s-tile-t">Another account on example.com</span>
<span class="s-tile-d">Server already known</span>
</span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
<div class="s-tile">
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
<span class="s-tile-txt"><span class="s-tile-t">A different provider</span></span>
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
</div>
</div>
</div>
</div>
</div>
<figcaption>
<span class="d-step is-alt">Add mode</span>
<span class="d-cap-title">Reuse what we already know</span>
<p class="d-cap-body">
<code>isAddMode</code> currently shows the same blank three-field form. Most second accounts live on
the same server as the first, so offer that first and skip discovery entirely.
</p>
</figcaption>
</figure>
</div>
</section>
<section class="d-section">
<h2 class="d-h2">Notes for the build</h2>
<hr class="d-rule" />
<ul class="d-notes">
<li>
<svg><use href="#i-globe" /></svg>
<div>
<div class="d-note-h">Discovery is three cheap probes, in order</div>
<p class="d-note-b">
From <code>ada@example.com</code>: try <code>https://example.com/.well-known/jmap</code>, then
<code>https://mail.example.com/.well-known/jmap</code>, then the same host the last account used.
First 200 wins. Budget the whole thing at two seconds and fall through to Path B on timeout — a
slow lookup must never be worse than the manual form.
</p>
</div>
</li>
<li>
<svg><use href="#i-shield" /></svg>
<div>
<div class="d-note-h">Nothing new on the wire</div>
<p class="d-note-b">
Every screen drives an existing store action — <code>loginViaWebmail</code>,
<code>loginViaPairing</code>, <code>login</code>. The redesign is routing and copy; the auth
surface, the state machine, and <code>parseQrLoginPayload</code> stay as they are.
</p>
</div>
</li>
<li>
<svg><use href="#i-alert" /></svg>
<div>
<div class="d-note-h">Back must always work</div>
<p class="d-note-b">
A multi-step flow earns a hardware-back handler on Android. Every step goes back one, and step 1
leaves the flow — never a trapped screen with a filled-in field you can't clear.
</p>
</div>
</li>
<li>
<svg><use href="#i-key" /></svg>
<div>
<div class="d-note-h">Let the OS help</div>
<p class="d-note-b">
<code>autoComplete="email"</code> and <code>textContentType="username"</code> on the address field,
<code>"password"</code> on the fallback field, so the platform password manager offers to fill and
save. The current screen sets neither.
</p>
</div>
</li>
</ul>
</section>
<p class="d-foot">Mockup · repos/react-native/mockups/login.html · static HTML, no interaction wired up</p>
</div>
+10 -6
View File
@@ -53,16 +53,19 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>Scan QR code</Text>
<Text style={styles.title}>Sign-in code</Text>
<View style={styles.closeButton} />
</View>
{permission?.granted ? (
<View style={styles.frameWrap}>
<View style={styles.frame} />
<Text style={styles.hint}>
Point your camera at the QR code shown on the webmail settings screen.
</Text>
<View style={styles.hintWrap}>
<Text style={styles.hintTitle}>Open Bulwark on your computer</Text>
<Text style={styles.hint}>
Settings → Devices → Add phone shows a code. Point the camera at it.
</Text>
</View>
</View>
) : (
<View style={styles.permissionWrap}>
@@ -105,11 +108,12 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.lg,
backgroundColor: 'transparent',
},
hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl },
hintTitle: { ...typography.bodySemibold, color: '#ffffff', textAlign: 'center' },
hint: {
...typography.caption,
color: '#ffffff',
color: 'rgba(255,255,255,0.8)',
textAlign: 'center',
paddingHorizontal: spacing.xxl,
},
permissionWrap: {
flex: 1,
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { describeLoginError } from '../login-errors';
function named(name: string, message: string): Error {
const err = new Error(message);
err.name = name;
return err;
}
describe('describeLoginError', () => {
it('turns a rejected credential into an actionable message', () => {
const copy = describeLoginError(named('AuthenticationError', 'Invalid credentials'));
expect(copy.title).toBe("That didn't work");
expect(copy.detail).toMatch(/app password/i);
});
it('names the host it could not reach', () => {
const copy = describeLoginError(named('NetworkError', 'Network request failed'), {
serverUrl: 'https://mail.example.com',
});
expect(copy.title).toBe("Can't reach mail.example.com");
});
it('falls back to a generic host label when the server is unknown', () => {
const copy = describeLoginError(named('TypeError', 'Network request failed'));
expect(copy.title).toBe("Can't reach the server");
});
it('explains a 404 from session discovery as a wrong address', () => {
const copy = describeLoginError(new Error('Session discovery failed: 404 Not Found'), {
serverUrl: 'https://example.com',
});
expect(copy.title).toBe('No mail server at example.com');
});
it('flags a certificate problem separately from a connection problem', () => {
const copy = describeLoginError(new Error('SSL certificate has expired'), {
serverUrl: 'https://mail.example.com',
});
expect(copy.title).toBe("Couldn't verify mail.example.com");
});
it('explains an expired pairing code', () => {
const copy = describeLoginError(new Error('Pairing code is invalid or has expired'));
expect(copy.title).toBe('That code has expired');
expect(copy.detail).toMatch(/fresh one/i);
});
it('passes through an unrecognised message rather than inventing a cause', () => {
const copy = describeLoginError(new Error('Teapot refused to brew'));
expect(copy.title).toBe('Sign-in failed');
expect(copy.detail).toBe('Teapot refused to brew');
});
it('handles non-Error throws', () => {
expect(describeLoginError(undefined).title).toBe('Sign-in failed');
expect(describeLoginError('boom').detail).toBe('boom');
});
});
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockSecureFetch } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
}));
vi.mock('../client-cert', () => ({
secureFetch: mockSecureFetch,
}));
import {
normalizeServerUrl,
emailDomain,
isEmailAddress,
serverCandidates,
probeJmapServer,
discoverServerForEmail,
} from '../server-discovery';
const SESSION_DOC = { capabilities: {}, apiUrl: '/jmap/', primaryAccounts: {} };
/**
* Answers `status` for the listed base URLs and rejects everything else. A 200
* yields a JMAP session document unless a body is given explicitly.
*/
function respondFor(statuses: Record<string, number | { status: number; body: unknown }>) {
mockSecureFetch.mockImplementation(async (url: string) => {
const base = url.replace(/\/\.well-known\/jmap$/, '');
const entry = statuses[base];
if (entry === undefined) throw new TypeError('Network request failed');
const { status, body } =
typeof entry === 'number' ? { status: entry, body: SESSION_DOC } : entry;
return { status, json: async () => body } as Response;
});
}
describe('normalizeServerUrl', () => {
it('adds https:// to a bare host', () => {
expect(normalizeServerUrl('mail.example.com')).toBe('https://mail.example.com');
});
it('strips trailing slashes', () => {
expect(normalizeServerUrl('https://mail.example.com//')).toBe('https://mail.example.com');
});
it('strips a pasted JMAP session endpoint', () => {
expect(normalizeServerUrl('https://mail.example.com/.well-known/jmap')).toBe(
'https://mail.example.com',
);
expect(normalizeServerUrl('mail.example.com/jmap')).toBe('https://mail.example.com');
});
it('keeps an explicit http scheme and port', () => {
expect(normalizeServerUrl('http://localhost:8080')).toBe('http://localhost:8080');
});
it('rejects non-http schemes', () => {
expect(normalizeServerUrl('ftp://mail.example.com')).toBeNull();
expect(normalizeServerUrl('bulwarkmail://connect')).toBeNull();
});
it('rejects input that is not a host', () => {
expect(normalizeServerUrl('')).toBeNull();
expect(normalizeServerUrl(' ')).toBeNull();
expect(normalizeServerUrl('not a host')).toBeNull();
expect(normalizeServerUrl('ada@example.com')).toBeNull();
// A single label can't be a public host — except localhost.
expect(normalizeServerUrl('example')).toBeNull();
expect(normalizeServerUrl('localhost')).toBe('https://localhost');
});
});
describe('emailDomain', () => {
it('extracts and lowercases the domain', () => {
expect(emailDomain('Ada@Example.COM')).toBe('example.com');
});
it('rejects anything that is not an address', () => {
expect(emailDomain('ada')).toBeNull();
expect(emailDomain('ada@localhost')).toBeNull();
expect(emailDomain('a@b@example.com')).toBeNull();
expect(isEmailAddress('ada@example.com')).toBe(true);
expect(isEmailAddress('ada')).toBe(false);
});
});
describe('serverCandidates', () => {
it('orders the bare domain ahead of the conventional subdomains', () => {
expect(serverCandidates('example.com')).toEqual([
'https://example.com',
'https://mail.example.com',
'https://webmail.example.com',
]);
});
});
describe('probeJmapServer', () => {
beforeEach(() => vi.clearAllMocks());
it('treats 200 as a hit', async () => {
respondFor({ 'https://mail.example.com': 200 });
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true);
});
it('treats 401 as a hit — an unauthenticated probe is meant to be rejected', async () => {
respondFor({ 'https://mail.example.com': 401 });
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true);
});
it('treats 404 and transport failures as a miss', async () => {
respondFor({ 'https://mail.example.com': 404 });
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(false);
await expect(probeJmapServer('https://nothing.example.com')).resolves.toBe(false);
});
it('rejects a 200 that is not a session document', async () => {
// A catch-all web server or captive portal answering 200 for every path.
respondFor({ 'https://example.com': { status: 200, body: '<!doctype html>' } });
await expect(probeJmapServer('https://example.com')).resolves.toBe(false);
});
it('rejects a 200 whose body is not JSON at all', async () => {
mockSecureFetch.mockResolvedValue({
status: 200,
json: async () => {
throw new SyntaxError('Unexpected token <');
},
} as unknown as Response);
await expect(probeJmapServer('https://example.com')).resolves.toBe(false);
});
it('gives up at the deadline instead of hanging', async () => {
mockSecureFetch.mockImplementation(
() => new Promise(() => {/* never settles */}),
);
await expect(probeJmapServer('https://slow.example.com', 20)).resolves.toBe(false);
});
});
describe('discoverServerForEmail', () => {
beforeEach(() => vi.clearAllMocks());
it('returns a known account server for the same domain without probing', async () => {
respondFor({});
const found = await discoverServerForEmail('ada@example.com', {
knownServerUrls: ['https://mail.example.com'],
});
expect(found).toBe('https://mail.example.com');
expect(mockSecureFetch).not.toHaveBeenCalled();
});
it('prefers the bare domain when several hosts answer', async () => {
respondFor({ 'https://example.com': 401, 'https://mail.example.com': 200 });
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe('https://example.com');
});
it('falls back to mail.<domain> when the bare domain is not a JMAP server', async () => {
respondFor({ 'https://example.com': 404, 'https://mail.example.com': 401 });
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe(
'https://mail.example.com',
);
});
it('skips a bare domain that only serves a marketing site', async () => {
respondFor({
'https://example.com': { status: 200, body: '<!doctype html>' },
'https://mail.example.com': 200,
});
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe(
'https://mail.example.com',
);
});
it('returns null when nothing answers, so the caller can ask', async () => {
respondFor({});
await expect(discoverServerForEmail('ada@example.org')).resolves.toBeNull();
});
it('returns null without touching the network for a non-address', async () => {
respondFor({});
await expect(discoverServerForEmail('ada')).resolves.toBeNull();
expect(mockSecureFetch).not.toHaveBeenCalled();
});
});
+102
View File
@@ -0,0 +1,102 @@
// Sign-in failures reach the UI as raw strings from the JMAP client — "Session
// discovery failed: 404 Not Found", "Network request failed". Those describe
// what the code was doing, not what the person should do next. This maps the
// ones we can recognise onto copy that names a likely cause and an action.
//
// Errors are matched by `name` and message text rather than `instanceof` so
// this module stays free of the api/ and expo dependency graph.
export interface LoginErrorCopy {
title: string;
detail?: string;
}
export interface LoginErrorContext {
/** Host shown in "can't reach X" copy. */
serverUrl?: string | null;
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : typeof err === 'string' ? err : '';
}
function nameOf(err: unknown): string {
return err instanceof Error ? err.name : '';
}
function hostLabel(serverUrl: string | null | undefined): string {
if (!serverUrl) return 'the server';
const host = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
return host || 'the server';
}
export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy {
const name = nameOf(err);
const message = messageOf(err);
const lower = message.toLowerCase();
const host = hostLabel(context.serverUrl);
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
return {
title: "That didn't work",
detail:
'Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.',
};
}
if (lower.includes('certificate') || lower.includes('ssl') || lower.includes('tls')) {
return {
title: `Couldn't verify ${host}`,
detail:
"The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
};
}
// The endpoint answered, but it isn't a JMAP server — almost always a
// mistyped host or a webmail that lives on a subpath.
if (lower.includes('session discovery failed') && /\b40[34]\b/.test(message)) {
return {
title: `No mail server at ${host}`,
detail: 'Double-check the address, or scan a sign-in code from the webmail instead.',
};
}
if (
name === 'NetworkError' ||
name === 'TypeError' ||
name === 'AbortError' ||
lower.includes('network request failed') ||
lower.includes('failed to fetch') ||
lower.includes('timeout') ||
lower.includes('timed out')
) {
return {
title: `Can't reach ${host}`,
detail: 'Check your connection and the server address, then try again.',
};
}
if (lower.includes('pairing code')) {
return {
title: 'That code has expired',
detail: 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.',
};
}
if (lower.includes('state mismatch')) {
return {
title: 'Sign-in was interrupted',
detail: "The response didn't match the request we started. Try signing in again.",
};
}
if (lower.includes('maximum of') && lower.includes('accounts')) {
return { title: message };
}
// Unrecognised: show what we were told rather than inventing a cause.
return {
title: 'Sign-in failed',
detail: message || 'Something went wrong. Try again.',
};
}
+170
View File
@@ -0,0 +1,170 @@
// Turn an email address into a JMAP server URL so the sign-in flow doesn't
// have to open with a "Server URL" field nobody knows the answer to.
//
// The probe is deliberately cheap and short-lived: a GET of
// `<base>/.well-known/jmap` against a handful of conventional hosts, all in
// parallel, with a hard deadline. Discovery is an optimisation — if it comes
// back empty the caller falls through to asking for the server by hand, so a
// slow or hostile network must never be worse than typing it in.
import { secureFetch } from './client-cert';
export const DISCOVERY_TIMEOUT_MS = 2500;
const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
const EMAIL_RE = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/;
/**
* Accept what people actually type — `mail.example.com`, `example.com/`,
* `https://mail.example.com/.well-known/jmap` — and return the base URL the
* JMAP client expects, or null if it can't be one.
*/
export function normalizeServerUrl(input: string): string | null {
let value = input.trim();
if (!value) return null;
const scheme = SCHEME_RE.exec(value);
if (scheme) {
const protocol = scheme[1].toLowerCase();
if (protocol !== 'http' && protocol !== 'https') return null;
value = `${protocol}://${value.slice(scheme[0].length)}`;
} else {
value = `https://${value}`;
}
// Someone pasting the endpoint they found in the webmail's settings gets the
// same result as someone typing the address they use in the browser.
value = value
.replace(/\/+$/, '')
.replace(/\/\.well-known\/jmap$/i, '')
.replace(/\/jmap$/i, '')
.replace(/\/+$/, '');
const host = value.slice(value.indexOf('://') + 3).split('/')[0];
if (!host || /[\s@]/.test(host)) return null;
const hostname = host.split(':')[0].toLowerCase();
if (!hostname) return null;
if (!hostname.includes('.') && hostname !== 'localhost') return null;
return value;
}
export function emailDomain(email: string): string | null {
const match = EMAIL_RE.exec(email.trim());
return match ? match[1].toLowerCase() : null;
}
export function isEmailAddress(value: string): boolean {
return emailDomain(value) !== null;
}
function hostOf(url: string): string {
return url.slice(url.indexOf('://') + 3).split('/')[0].split(':')[0].toLowerCase();
}
/**
* Hosts worth probing for `domain`, most likely first. The order is also the
* tie-break order when several respond.
*/
export function serverCandidates(domain: string): string[] {
return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`];
}
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return new Promise<T>((resolve) => {
const timer = setTimeout(() => resolve(fallback), ms);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch(() => {
clearTimeout(timer);
resolve(fallback);
});
});
}
function looksLikeSession(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
return 'capabilities' in body || 'apiUrl' in body || 'primaryAccounts' in body;
}
/**
* True when `baseUrl` looks like a JMAP server.
*
* A 401 counts as a hit — an unauthenticated probe of Stalwart's session
* endpoint is *supposed* to be rejected, and that rejection is proof the
* endpoint is there. A 200, on the other hand, has to actually contain a
* session document: a captive portal or a catch-all web server will happily
* answer 200 with HTML for any path we ask for.
*/
export async function probeJmapServer(
baseUrl: string,
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
): Promise<boolean> {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const abortTimer = setTimeout(() => controller?.abort(), timeoutMs);
const request = (async () => {
try {
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Accept: 'application/json' },
signal: controller?.signal,
});
if (response.status === 401) return true;
if (response.status !== 200) return false;
return looksLikeSession(await response.json());
} catch {
return false;
}
})();
try {
// The abort signal doesn't reach the native client-certificate path, so
// race the whole thing rather than trusting the request to give up.
return await withTimeout(request, timeoutMs, false);
} finally {
clearTimeout(abortTimer);
}
}
export interface DiscoveryOptions {
/** Servers of already-registered accounts, trusted without a probe. */
knownServerUrls?: string[];
timeoutMs?: number;
}
/**
* Best guess at the mail server for `email`, or null if nothing answered in
* time. Callers treat null as "ask the user".
*/
export async function discoverServerForEmail(
email: string,
options: DiscoveryOptions = {},
): Promise<string | null> {
const domain = emailDomain(email);
if (!domain) return null;
// A server we're already signed in to for this domain needs no probe, and
// covers the common case of adding a second account on the same host.
const known = (options.knownServerUrls ?? [])
.map(normalizeServerUrl)
.filter((url): url is string => url !== null);
const alreadyKnown = known.find((url) => {
const host = hostOf(url);
return host === domain || host.endsWith(`.${domain}`);
});
if (alreadyKnown) return alreadyKnown;
const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
const candidates = serverCandidates(domain);
const results = await Promise.all(
candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs)) ? candidate : null)),
);
// `find` keeps candidate order, so a bare domain wins over `mail.` even when
// both answer.
return results.find((url): url is string => url !== null) ?? null;
}
+338 -270
View File
@@ -1,16 +1,23 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { BackHandler } from 'react-native';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
import { QrScanModal } from '../components/QrScanModal';
import { parseQrLoginPayload } from '../lib/oauth';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
import {
discoverServerForEmail,
emailDomain,
isEmailAddress,
normalizeServerUrl,
} from '../lib/server-discovery';
import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors';
import LoginShell from './login/LoginShell';
import ChooseStep from './login/ChooseStep';
import EmailStep from './login/EmailStep';
import ServerStep from './login/ServerStep';
import ConfirmStep from './login/ConfirmStep';
import PasswordStep from './login/PasswordStep';
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
interface LoginScreenProps {
onLogin?: () => void;
@@ -18,288 +25,349 @@ interface LoginScreenProps {
onCancel?: () => void;
}
export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
/**
* Sign-in, one question per screen, cheapest question first.
*
* The routes that need no server address — a paired QR code, or an email we
* can discover a server from — come first; the manual server + password form
* is the fallback rather than the greeting. Every step drives one of the
* existing auth-store actions, so the credential handling below is unchanged
* from the single-form version this replaced.
*/
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
const storeError = useAuthStore((state) => state.error);
const [serverUrl, setServerUrl] = React.useState('');
const accounts = useAccountStore((state) => state.accounts);
const activeAccountId = useAccountStore((state) => state.activeAccountId);
const [step, setStep] = React.useState<StepName>('choose');
const [history, setHistory] = React.useState<StepName[]>([]);
const [email, setEmail] = React.useState('');
const [serverInput, setServerInput] = React.useState('');
const [serverUrl, setServerUrl] = React.useState('');
const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
const [searching, setSearching] = React.useState(false);
const [busy, setBusy] = React.useState<SigningInPhase | null>(null);
const [scannerVisible, setScannerVisible] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
// Servers we already hold credentials for. Discovery trusts these without a
// probe, which makes "second account on the same host" instant.
const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]);
const knownServerUrl = React.useMemo(() => {
const active = accounts.find((a) => a.id === activeAccountId);
return active?.serverUrl ?? accounts[0]?.serverUrl ?? null;
}, [accounts, activeAccountId]);
const handleLogin = async () => {
if (!canSubmit) {
return;
}
try {
await login(serverUrl.trim(), email.trim(), password, { addAccount: isAddMode });
onLogin?.();
} catch {
// Store state already contains the user-facing error.
}
};
const handleWebmailHandoff = async () => {
if (!canHandoff) return;
try {
const before = useAuthStore.getState().isAuthenticated;
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
// loginViaWebmail swallows the "user cancelled the browser" case
// silently — only fire the navigation hook when authentication
// actually completed.
const after = useAuthStore.getState().isAuthenticated;
if (after && !before) {
onLogin?.();
} else if (isAddMode && after) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const handleScanned = async (data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
return;
}
try {
const before = useAuthStore.getState().isAuthenticated;
if (payload.kind === 'connect') {
// Server-bootstrap QR: fill the field and run the normal browser
// handoff so the user still authenticates in the webmail.
setServerUrl(payload.webmailUrl);
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
} else {
// Cross-device pairing QR: redeem the one-time code for tokens, no
// browser round-trip needed.
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
}
const after = useAuthStore.getState().isAuthenticated;
if ((after && !before) || (isAddMode && after)) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
clearError();
}
setter(value);
const goTo = React.useCallback(
(next: StepName) => {
clearError();
setNotice(null);
setHistory((prev) => [...prev, step]);
setStep(next);
},
[clearError],
[clearError, step],
);
const onChangeServerUrl = React.useMemo(() => updateField(setServerUrl), [updateField]);
const onChangeEmail = React.useMemo(() => updateField(setEmail), [updateField]);
const onChangePassword = React.useMemo(() => updateField(setPassword), [updateField]);
const goBack = React.useCallback(() => {
if (history.length === 0) return;
clearError();
setNotice(null);
setStep(history[history.length - 1]);
setHistory((prev) => prev.slice(0, -1));
}, [clearError, history]);
// Android hardware back mirrors the on-screen back: one step at a time, and
// out of the flow entirely from the first step.
React.useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (busy || searching) return true;
if (scannerVisible) {
setScannerVisible(false);
return true;
}
if (history.length > 0) {
goBack();
return true;
}
if (isAddMode && onCancel) {
onCancel();
return true;
}
return false;
});
return () => subscription.remove();
}, [busy, searching, scannerVisible, history.length, goBack, isAddMode, onCancel]);
// ── flows ──────────────────────────────────────────────
const finishIfSignedIn = React.useCallback(
(wasAuthenticated: boolean) => {
const isAuthenticated = useAuthStore.getState().isAuthenticated;
// In add mode the store was already authenticated before we started, so
// the before/after comparison can't be the only signal.
if ((isAuthenticated && !wasAuthenticated) || (isAddMode && isAuthenticated)) {
onLogin?.();
return true;
}
return false;
},
[isAddMode, onLogin],
);
const runHandoff = React.useCallback(
async (target: string) => {
setNotice(null);
setBusy('browser');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginViaWebmail(target, { addAccount: isAddMode });
// loginViaWebmail resolves quietly when the user closes the browser —
// that's a cancellation, not a failure, so leave them where they were.
finishIfSignedIn(wasAuthenticated);
} catch (err) {
setNotice(describeLoginError(err, { serverUrl: target }));
} finally {
setBusy(null);
}
},
[finishIfSignedIn, isAddMode, loginViaWebmail],
);
const handleEmailContinue = React.useCallback(async () => {
const value = email.trim();
if (!isEmailAddress(value)) {
setNotice({
title: 'Enter a full email address',
detail: 'Like ada@example.com. If you sign in with a username instead, use “I know my server address”.',
});
return;
}
setNotice(null);
setSearching(true);
try {
const found = await discoverServerForEmail(value, { knownServerUrls });
if (found) {
setServerUrl(found);
setServerInput(found);
goTo('confirm');
} else {
setFailedDomain(emailDomain(value));
setServerInput('');
goTo('server');
}
} finally {
setSearching(false);
}
}, [email, goTo, knownServerUrls]);
const handleServerContinue = React.useCallback(() => {
const normalized = normalizeServerUrl(serverInput);
if (!normalized) {
setNotice({
title: "That doesn't look like a server address",
detail: 'Try the address you use for webmail, like mail.example.com.',
});
return;
}
setServerUrl(normalized);
goTo('confirm');
}, [goTo, serverInput]);
const handlePasswordSubmit = React.useCallback(async () => {
const target = serverUrl || normalizeServerUrl(serverInput);
if (!target) {
setNotice({ title: 'We need a server address first' });
return;
}
if (!email.trim() || !password) {
setNotice({ title: 'Enter your email and password' });
return;
}
setNotice(null);
setBusy('connecting');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await login(target, email.trim(), password, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated);
} catch (err) {
setNotice(describeLoginError(err, { serverUrl: target }));
} finally {
setBusy(null);
}
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl]);
const handleScanned = React.useCallback(
async (data: string) => {
setScannerVisible(false);
const payload = parseQrLoginPayload(data);
if (!payload) {
setNotice({
title: "That code isn't a Bulwark sign-in code",
detail: 'Open Bulwark on the web, then Settings → Devices to show one.',
});
return;
}
if (payload.kind === 'connect') {
// Server-bootstrap code: it carries the address, the user still
// authenticates in the webmail.
setServerUrl(payload.webmailUrl);
setServerInput(payload.webmailUrl);
await runHandoff(payload.webmailUrl);
return;
}
// Cross-device pairing code: redeem it for tokens, no browser needed.
setNotice(null);
setBusy('pairing');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated);
} catch (err) {
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl }));
} finally {
setBusy(null);
}
},
[finishIfSignedIn, isAddMode, loginViaPairing, runHandoff],
);
// ── render ─────────────────────────────────────────────
if (busy) {
return (
<SigningInStep
phase={busy}
serverUrl={serverUrl || knownServerUrl}
email={email.trim() || null}
/>
);
}
const canGoBack = history.length > 0;
const shellProps = {
onBack: canGoBack ? goBack : undefined,
onClose: isAddMode && onCancel ? onCancel : undefined,
};
if (step === 'choose') {
// A leftover store error ("Session expired") is the reason some people
// land back here, so it belongs on this screen.
const chooseNotice = notice ?? (storeError ? { title: storeError } : null);
return (
<>
<LoginShell {...shellProps} centered showFooter>
<ChooseStep
isAddMode={isAddMode}
accounts={accounts}
knownServerUrl={isAddMode ? knownServerUrl : null}
notice={chooseNotice}
onScan={() => setScannerVisible(true)}
onUseEmail={() => goTo('email')}
onUseKnownServer={() => {
if (!knownServerUrl) return;
setServerUrl(knownServerUrl);
setServerInput(knownServerUrl);
goTo('confirm');
}}
onManualSetup={() => {
setFailedDomain(null);
goTo('server');
}}
/>
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => void handleScanned(data)}
/>
</>
);
}
return (
<SafeAreaView style={styles.container}>
{isAddMode && onCancel ? (
<Pressable onPress={onCancel} style={styles.cancelButton} hitSlop={10}>
<X size={24} color={c.text} />
</Pressable>
) : null}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.keyboardView}
>
<View style={styles.content}>
{/* Logo & Branding */}
<View style={styles.branding}>
<View style={styles.logoContainer}>
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.logoImage}
resizeMode="contain"
/>
</View>
<Text style={styles.appName}>{isAddMode ? 'Add account' : 'Bulwark Mail'}</Text>
<Text style={styles.tagline}>
{isAddMode ? 'Sign in to a second account' : 'Secure. Private. Yours.'}
</Text>
</View>
<>
<LoginShell {...shellProps}>
{step === 'email' ? (
<EmailStep
isAddMode={isAddMode}
value={email}
onChange={(value) => {
setNotice(null);
setEmail(value);
}}
onSubmit={() => void handleEmailContinue()}
onKnowServer={() => {
setFailedDomain(null);
goTo('server');
}}
isSearching={searching}
notice={notice}
/>
) : null}
{/* Login Form */}
<View style={styles.form}>
<Input
label="Server URL"
placeholder="https://mail.example.com"
value={serverUrl}
onChangeText={onChangeServerUrl}
autoCapitalize="none"
keyboardType="url"
autoCorrect={false}
/>
{step === 'server' ? (
<ServerStep
value={serverInput}
onChange={(value) => {
setNotice(null);
setServerInput(value);
}}
onSubmit={handleServerContinue}
onScan={() => setScannerVisible(true)}
failedDomain={failedDomain}
notice={notice}
/>
) : null}
<Input
label="Email or Username"
placeholder="you@example.com"
value={email}
onChangeText={onChangeEmail}
autoCapitalize="none"
keyboardType="email-address"
autoCorrect={false}
/>
{step === 'confirm' ? (
<ConfirmStep
serverUrl={serverUrl}
onContinue={() => void runHandoff(serverUrl)}
onChangeServer={() => {
setFailedDomain(null);
goTo('server');
}}
onUsePassword={() => goTo('password')}
notice={notice}
/>
) : null}
<Input
label="Password"
placeholder="Enter your password"
value={password}
onChangeText={onChangePassword}
secureTextEntry={!showPassword}
onSubmitEditing={() => {
void handleLogin();
}}
rightIcon={
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={20} color={c.textMuted} />
) : (
<Eye size={20} color={c.textMuted} />
)}
</Pressable>
}
/>
<Button
variant="default"
size="lg"
onPress={() => {
void handleLogin();
}}
disabled={!canSubmit}
loading={isLoading}
style={styles.loginButton}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
{/* OAuth divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or continue with</Text>
<View style={styles.dividerLine} />
</View>
{/* Webmail-mediated login */}
<View style={styles.oauthRow}>
<Button
variant="outline"
size="md"
onPress={() => {
void handleWebmailHandoff();
}}
disabled={!canHandoff || isLoading}
>
Sign in via webmail
</Button>
<Button
variant="ghost"
size="md"
onPress={() => setScannerVisible(true)}
disabled={isLoading}
icon={<QrCode size={18} color={c.text} />}
>
Scan QR code
</Button>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
<Text style={styles.footerLink}>Privacy Policy</Text>
</View>
</View>
</KeyboardAvoidingView>
{step === 'password' ? (
<PasswordStep
serverUrl={serverUrl}
email={email}
password={password}
onChangeEmail={(value) => {
setNotice(null);
setEmail(value);
}}
onChangePassword={(value) => {
setNotice(null);
setPassword(value);
}}
onSubmit={() => void handlePasswordSubmit()}
notice={notice}
/>
) : null}
</LoginShell>
<QrScanModal
visible={scannerVisible}
onClose={() => setScannerVisible(false)}
onScanned={(data) => {
void handleScanned(data);
}}
onScanned={(data) => void handleScanned(data)}
/>
</SafeAreaView>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
cancelButton: {
position: 'absolute',
top: 50,
left: 16,
zIndex: 10,
padding: 8,
},
keyboardView: { flex: 1 },
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: spacing.xxl,
},
branding: { alignItems: 'center', marginBottom: 40 },
logoContainer: {
width: 80,
height: 80,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
},
logoImage: {
width: 72,
height: 72,
},
appName: { ...typography.h1, color: c.text },
tagline: { ...typography.caption, color: c.textMuted, marginTop: spacing.xs },
form: { gap: spacing.lg },
loginButton: {
marginTop: spacing.sm,
},
errorText: {
...typography.caption,
color: c.error,
textAlign: 'center',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: spacing.md,
gap: spacing.md,
},
dividerLine: { flex: 1, height: 1, backgroundColor: c.border },
dividerText: { ...typography.caption, color: c.textMuted },
oauthRow: { gap: spacing.md },
footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs },
footerText: { ...typography.caption, color: c.textMuted },
footerLink: { ...typography.caption, color: c.textLink },
});
}
+184
View File
@@ -0,0 +1,184 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Image } from 'react-native';
import { QrCode, Mail, Plus, Server } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { AccountEntry } from '../../stores/account-store';
import OptionTile from './OptionTile';
import LoginNotice from './LoginNotice';
// The white mark disappears on the light palette, so pick per theme. `require`
// can't take an expression, hence the pair.
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png');
interface ChooseStepProps {
isAddMode: boolean;
accounts: AccountEntry[];
/** Host of the signed-in account, offered as a shortcut in add mode. */
knownServerUrl: string | null;
onScan: () => void;
onUseEmail: () => void;
onUseKnownServer: () => void;
onManualSetup: () => void;
notice?: { title: string; detail?: string } | null;
disabled?: boolean;
}
function hostOf(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
function initialsOf(account: AccountEntry): string {
const source = account.displayName || account.email || account.username;
const parts = source.replace(/@.*$/, '').split(/[.\s_-]+/).filter(Boolean);
const letters = parts.slice(0, 2).map((part) => part[0]);
return (letters.join('') || source[0] || '?').toUpperCase();
}
/**
* The entry point. Leads with the two routes that don't require knowing a
* server address; manual setup stays one tap away for people who do.
*/
export default function ChooseStep({
isAddMode,
accounts,
knownServerUrl,
onScan,
onUseEmail,
onUseKnownServer,
onManualSetup,
notice,
disabled = false,
}: ChooseStepProps) {
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
{isAddMode ? (
<View style={styles.addHeading}>
<Text style={styles.title}>Add another account</Text>
{accounts.length > 0 ? (
<Text style={styles.subtitle}>You&apos;re signed in to these already.</Text>
) : null}
</View>
) : (
<View style={styles.branding}>
<Image
source={theme === 'light' ? LOGO_LIGHT : LOGO_DARK}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.appName}>Bulwark Mail</Text>
<Text style={styles.tagline}>Secure. Private. Yours.</Text>
</View>
)}
{isAddMode && accounts.length > 0 ? (
<View style={styles.accountList}>
{accounts.map((account) => (
<View key={account.id} style={styles.accountRow}>
<View style={[styles.avatar, { backgroundColor: account.avatarColor }]}>
<Text style={styles.avatarText}>{initialsOf(account)}</Text>
</View>
<View style={styles.accountText}>
<Text style={styles.accountName} numberOfLines={1}>
{account.displayName || account.username}
</Text>
<Text style={styles.accountEmail} numberOfLines={1}>
{account.email || account.username}
</Text>
</View>
</View>
))}
</View>
) : null}
{notice ? (
<View style={styles.notice}>
<LoginNotice title={notice.title} detail={notice.detail} />
</View>
) : null}
<View style={styles.options}>
{isAddMode && knownServerUrl ? (
<OptionTile
emphasis="primary"
title={`Another account on ${hostOf(knownServerUrl)}`}
description="Server already known — no setup needed"
renderIcon={(color, size) => <Plus size={size} color={color} />}
onPress={onUseKnownServer}
disabled={disabled}
/>
) : (
<OptionTile
emphasis="primary"
title="Scan a sign-in code"
description="Already signed in on the web? Fastest way in."
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
)}
<OptionTile
title={isAddMode ? 'An account somewhere else' : 'Use my email address'}
description="We'll find your mail server"
renderIcon={(color, size) => <Mail size={size} color={color} />}
onPress={onUseEmail}
disabled={disabled}
/>
{isAddMode && knownServerUrl ? (
<OptionTile
title="Scan a sign-in code"
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
/>
) : null}
</View>
<Pressable onPress={onManualSetup} disabled={disabled} hitSlop={8} style={styles.manual}>
<Server size={14} color={c.textMuted} />
<Text style={styles.manualText}>Enter server details manually</Text>
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.xl },
branding: { alignItems: 'center', gap: spacing.xs, marginBottom: spacing.md },
logo: { width: 64, height: 64, marginBottom: spacing.sm },
appName: { ...typography.h1, color: c.text },
tagline: { ...typography.caption, color: c.textMuted },
addHeading: { gap: spacing.xs },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
accountList: { gap: spacing.md },
accountRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
avatar: { width: 36, height: 36, borderRadius: radius.full, alignItems: 'center', justifyContent: 'center' },
avatarText: { ...typography.captionMedium, color: '#ffffff' },
accountText: { flex: 1 },
accountName: { ...typography.bodyMedium, color: c.text },
accountEmail: { ...typography.caption, color: c.textMuted },
notice: { marginTop: -spacing.xs },
options: { gap: spacing.md },
manual: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
},
manualText: { ...typography.body, color: c.textMuted },
});
}
+119
View File
@@ -0,0 +1,119 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Globe } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button } from '../../components';
import LoginNotice from './LoginNotice';
interface ConfirmStepProps {
serverUrl: string;
onContinue: () => void;
onChangeServer: () => void;
onUsePassword: () => void;
notice?: { title: string; detail?: string } | null;
}
function hostOf(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
/**
* Names the server before the browser opens. A sign-in that jumps to a browser
* with no warning reads as a bug; saying which server, and why the password
* isn't asked for here, turns the detour into the point.
*/
export default function ConfirmStep({
serverUrl,
onContinue,
onChangeServer,
onUsePassword,
notice,
}: ConfirmStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>Found your mail server</Text>
<Text style={styles.subtitle}>Next you&apos;ll sign in on your provider&apos;s own page.</Text>
</View>
<View style={styles.card}>
<View style={styles.cardIcon}>
<Globe size={19} color={c.primary} />
</View>
<View style={styles.cardText}>
<Text style={styles.cardTitle} numberOfLines={1}>
{hostOf(serverUrl)}
</Text>
<View style={styles.cardMetaRow}>
<View style={styles.dot} />
<Text style={styles.cardMeta}>
{serverUrl.startsWith('https://') ? 'JMAP · secure connection' : 'JMAP · unencrypted connection'}
</Text>
</View>
</View>
</View>
<LoginNotice
tone="info"
title="Your password stays with your provider"
detail="You'll type it on their page, not into this app. Bulwark keeps a token it can revoke."
/>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onContinue}>
Continue
</Button>
<View style={styles.links}>
<Pressable onPress={onUsePassword} hitSlop={8}>
<Text style={styles.link}>Sign in with a password instead</Text>
</Pressable>
<Pressable onPress={onChangeServer} hitSlop={8}>
<Text style={styles.linkMuted}>Use a different server</Text>
</Pressable>
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
card: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
borderRadius: radius.lg,
padding: spacing.lg,
},
cardIcon: {
width: 38,
height: 38,
borderRadius: radius.md,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
cardText: { flex: 1, gap: 2 },
cardTitle: { ...typography.baseMedium, color: c.text },
cardMetaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
dot: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: c.success },
cardMeta: { ...typography.caption, color: c.textSecondary },
links: { alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs },
link: { ...typography.body, color: c.textLink },
linkMuted: { ...typography.body, color: c.textMuted },
});
}
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Mail } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
interface EmailStepProps {
isAddMode: boolean;
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onKnowServer: () => void;
isSearching: boolean;
notice?: { title: string; detail?: string } | null;
}
/**
* One field, keyboard already up. The button stays enabled and validates on
* press, so nobody is left guessing which field disabled it.
*/
export default function EmailStep({
isAddMode,
value,
onChange,
onSubmit,
onKnowServer,
isSearching,
notice,
}: EmailStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>
{isAddMode ? "What's the other address?" : "What's your email address?"}
</Text>
<Text style={styles.subtitle}>
That&apos;s usually all we need — we&apos;ll look up the mail server for you.
</Text>
</View>
<Input
placeholder="you@example.com"
value={value}
onChangeText={onChange}
autoFocus
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="username"
keyboardType="email-address"
returnKeyType="go"
editable={!isSearching}
onSubmitEditing={onSubmit}
leftIcon={<Mail size={17} color={c.textMuted} />}
/>
<Text style={styles.hint}>Works with Bulwark, Stalwart, and any JMAP mail server.</Text>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onSubmit} loading={isSearching}>
{isSearching ? 'Looking up your server…' : 'Continue'}
</Button>
<Pressable onPress={onKnowServer} disabled={isSearching} hitSlop={8} style={styles.link}>
<Text style={styles.linkText}>I know my server address</Text>
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm },
link: { alignItems: 'center', paddingVertical: spacing.sm },
linkText: { ...typography.body, color: c.textMuted },
});
}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { AlertTriangle, Lock } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface LoginNoticeProps {
title: string;
detail?: string;
tone?: 'error' | 'info';
}
/** Inline banner for a sign-in failure, or a reassurance about where a
* password ends up. Errors lead with what went wrong, then what to do. */
export default function LoginNotice({ title, detail, tone = 'error' }: LoginNoticeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isError = tone === 'error';
const Icon = isError ? AlertTriangle : Lock;
return (
<View
style={[styles.banner, isError ? styles.bannerError : styles.bannerInfo]}
accessibilityLiveRegion={isError ? 'polite' : 'none'}
>
<Icon size={16} color={isError ? c.error : c.textMuted} style={styles.icon} />
<View style={styles.text}>
<Text style={[styles.title, isError ? styles.titleError : styles.titleInfo]}>{title}</Text>
{detail ? <Text style={styles.detail}>{detail}</Text> : null}
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
flexDirection: 'row',
gap: spacing.sm,
borderRadius: radius.md,
padding: spacing.md,
},
bannerError: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.errorBorder },
bannerInfo: { backgroundColor: 'transparent', paddingHorizontal: 0 },
icon: { marginTop: 1 },
text: { flex: 1, gap: 2 },
title: { ...typography.captionMedium },
titleError: { color: c.error },
titleInfo: { color: c.textSecondary },
detail: { ...typography.caption, color: c.textSecondary, lineHeight: 17 },
});
}
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import {
View,
Text,
Pressable,
ScrollView,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { ArrowLeft, X } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
interface LoginShellProps {
children: React.ReactNode;
/** Renders a back chevron. Omit on the first step of the flow. */
onBack?: () => void;
/** Renders a close X on the right — add-account mode only. */
onClose?: () => void;
/** Vertically centres the content instead of stacking from the top. */
centered?: boolean;
showFooter?: boolean;
}
/**
* Shared chrome for every sign-in step: safe area, keyboard avoidance, the
* back/close affordances, and the version footer. Steps supply only their own
* content so they all sit on the same grid.
*/
export default function LoginShell({
children,
onBack,
onClose,
centered = false,
showFooter = false,
}: LoginShellProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.flex}
>
<View style={styles.header}>
{onBack ? (
<Pressable onPress={onBack} hitSlop={12} style={styles.headerButton}>
<ArrowLeft size={22} color={c.textSecondary} />
</Pressable>
) : (
<View style={styles.headerButton} />
)}
{onClose ? (
<Pressable onPress={onClose} hitSlop={12} style={styles.headerButton}>
<X size={22} color={c.textSecondary} />
</Pressable>
) : (
<View style={styles.headerButton} />
)}
</View>
<ScrollView
style={styles.flex}
contentContainerStyle={[styles.content, centered && styles.contentCentered]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
{showFooter ? (
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
</View>
) : null}
</KeyboardAvoidingView>
</SafeAreaView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
flex: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
height: 44,
},
headerButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
content: {
paddingHorizontal: spacing.xxl,
paddingBottom: spacing.xxxl,
},
contentCentered: { flexGrow: 1, justifyContent: 'center' },
footer: { alignItems: 'center', paddingBottom: spacing.lg, gap: spacing.xs },
footerText: { ...typography.caption, color: c.textMuted },
});
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { ChevronRight } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface OptionTileProps {
title: string;
description?: string;
/** Receives the colour the tile's text renders in. */
renderIcon: (color: string, size: number) => React.ReactNode;
onPress: () => void;
emphasis?: 'primary' | 'default';
disabled?: boolean;
}
/**
* A full-width choice on the sign-in screen: icon, what it does, and what it
* costs you. Used instead of a stack of buttons so each route can say enough
* to be picked without trial and error.
*/
export default function OptionTile({
title,
description,
renderIcon,
onPress,
emphasis = 'default',
disabled = false,
}: OptionTileProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isPrimary = emphasis === 'primary';
const contentColor = isPrimary ? c.primaryForeground : c.text;
return (
<Pressable
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={description ? `${title}. ${description}` : title}
style={({ pressed }) => [
styles.tile,
isPrimary ? styles.tilePrimary : styles.tileDefault,
pressed && !disabled && (isPrimary ? styles.pressedPrimary : styles.pressedDefault),
disabled && styles.disabled,
]}
>
<View style={styles.icon}>{renderIcon(contentColor, 22)}</View>
<View style={styles.text}>
<Text style={[styles.title, { color: contentColor }]}>{title}</Text>
{description ? (
<Text
style={[
styles.description,
{ color: isPrimary ? c.primaryForeground : c.textSecondary },
isPrimary && styles.descriptionPrimary,
]}
>
{description}
</Text>
) : null}
</View>
<ChevronRight size={18} color={contentColor} style={styles.chevron} />
</Pressable>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
tile: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
borderRadius: radius.lg,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
borderWidth: 1,
},
tileDefault: { backgroundColor: c.surface, borderColor: c.border },
tilePrimary: { backgroundColor: c.primary, borderColor: c.primary },
pressedDefault: { backgroundColor: c.surfaceHover },
pressedPrimary: { backgroundColor: c.primaryDark },
disabled: { opacity: 0.5 },
icon: { width: 22, alignItems: 'center' },
text: { flex: 1, gap: 2 },
title: { ...typography.baseMedium },
description: { ...typography.caption },
descriptionPrimary: { opacity: 0.8 },
chevron: { opacity: 0.5 },
});
}
+98
View File
@@ -0,0 +1,98 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Eye, EyeOff } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
interface PasswordStepProps {
serverUrl: string;
email: string;
password: string;
onChangeEmail: (value: string) => void;
onChangePassword: (value: string) => void;
onSubmit: () => void;
notice?: { title: string; detail?: string } | null;
}
function hostOf(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
/** The fallback for servers without the browser hand-off — and the only step
* where a password is typed into the app. */
export default function PasswordStep({
serverUrl,
email,
password,
onChangeEmail,
onChangePassword,
onSubmit,
notice,
}: PasswordStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [showPassword, setShowPassword] = React.useState(false);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>Sign in to {hostOf(serverUrl)}</Text>
<Text style={styles.subtitle}>
Your password is sent to this server only, and stored in the device keychain.
</Text>
</View>
<Input
label="Email or username"
placeholder="you@example.com"
value={email}
onChangeText={onChangeEmail}
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="username"
keyboardType="email-address"
/>
<Input
label="Password"
placeholder="Enter your password"
value={password}
onChangeText={onChangePassword}
autoFocus={Boolean(email)}
secureTextEntry={!showPassword}
autoComplete="current-password"
textContentType="password"
returnKeyType="go"
onSubmitEditing={onSubmit}
rightIcon={
<Pressable
onPress={() => setShowPassword((v) => !v)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
</Pressable>
}
/>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onSubmit}>
Sign in
</Button>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
});
}
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Globe, QrCode } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
import LoginNotice from './LoginNotice';
interface ServerStepProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onScan: () => void;
/** Set when we got here because discovery came back empty for this domain. */
failedDomain?: string | null;
notice?: { title: string; detail?: string } | null;
}
/**
* Asks for the address people actually know — the one they type in the browser
* to reach their webmail — not the JMAP session endpoint. The scheme and the
* `/.well-known/jmap` suffix are our problem, not theirs.
*/
export default function ServerStep({
value,
onChange,
onSubmit,
onScan,
failedDomain,
notice,
}: ServerStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>Where does your mail live?</Text>
<Text style={styles.subtitle}>
{failedDomain
? `We couldn't find a server for ${failedDomain}. If you self-host, the address is the one you use for webmail.`
: 'Enter the address you use to reach your webmail.'}
</Text>
</View>
<Input
label="Server address"
placeholder="mail.example.com"
value={value}
onChangeText={onChange}
autoFocus
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="go"
onSubmitEditing={onSubmit}
leftIcon={<Globe size={17} color={c.textMuted} />}
/>
<Text style={styles.hint}>Same address you type in the browser. We&apos;ll add https:// for you.</Text>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onSubmit}>
Continue
</Button>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}>
<QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}>
Not sure? Scan a sign-in code instead — it carries the address with it.
</Text>
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.lg },
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm },
scanRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, paddingVertical: spacing.sm },
scanText: { ...typography.caption, color: c.textMuted, flex: 1, lineHeight: 17 },
});
}
+102
View File
@@ -0,0 +1,102 @@
import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
// The white mark disappears on the light palette, so pick per theme.
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png');
export type SigningInPhase = 'browser' | 'connecting' | 'pairing';
interface SigningInStepProps {
phase: SigningInPhase;
serverUrl: string | null;
email: string | null;
}
function hostOf(url: string | null): string {
if (!url) return 'your server';
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
function initialsOf(email: string): string {
const local = email.replace(/@.*$/, '');
const parts = local.split(/[.\s_-]+/).filter(Boolean);
const letters = parts.slice(0, 2).map((part) => part[0]);
return (letters.join('') || local[0] || '?').toUpperCase();
}
/**
* Covers the wait while a sign-in is in flight. Naming the server and the
* current stage makes a slow first connection legible instead of a bare
* spinner that could mean anything.
*/
export default function SigningInStep({ phase, serverUrl, email }: SigningInStepProps) {
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
const host = hostOf(serverUrl);
const copy: Record<SigningInPhase, { title: string; detail: string }> = {
browser: {
title: `Opening ${host}`,
detail: 'Finish signing in on the page that just opened. We’ll take it from there.',
},
connecting: { title: `Connecting to ${host}`, detail: 'Setting up your mailbox…' },
pairing: { title: 'Redeeming your sign-in code', detail: `Pairing this device with ${host}…` },
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
{email ? (
<View style={styles.avatar}>
<Text style={styles.avatarText}>{initialsOf(email)}</Text>
</View>
) : (
<Image
source={theme === 'light' ? LOGO_LIGHT : LOGO_DARK}
style={styles.logo}
resizeMode="contain"
/>
)}
{email ? <Text style={styles.email}>{email}</Text> : null}
<Text style={styles.title}>{copy[phase].title}</Text>
<Text style={styles.detail}>{copy[phase].detail}</Text>
<ActivityIndicator size="small" color={c.primary} style={styles.spinner} />
</View>
</SafeAreaView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.xxl,
gap: spacing.xs,
},
avatar: {
width: 60,
height: 60,
borderRadius: radius.full,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
avatarText: { ...typography.h2, color: c.primaryForeground },
logo: { width: 60, height: 60, marginBottom: spacing.md },
email: { ...typography.caption, color: c.textMuted },
title: { ...typography.h2, color: c.text, textAlign: 'center' },
detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
spinner: { marginTop: spacing.xl },
});
}