Skip to content

Commit 6e5db17

Browse files
committed
improve login flow from command
1 parent 2a4aa1f commit 6e5db17

11 files changed

Lines changed: 370 additions & 33 deletions

File tree

OpenVoting.Client/src/app.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,10 +423,37 @@ button.ghost {
423423
color: var(--banner-warning-text);
424424
}
425425

426+
.banner code,
427+
.muted code {
428+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
429+
font-weight: 700;
430+
font-size: 1em;
431+
background: color-mix(in srgb, var(--surface) 70%, transparent);
432+
border: 1px solid var(--border);
433+
border-radius: 6px;
434+
padding: 1px 6px;
435+
}
436+
426437
.muted {
427438
color: var(--text-muted);
428439
}
429440

441+
.command-block {
442+
margin: 8px 0 0;
443+
padding: 10px 12px;
444+
border-radius: 10px;
445+
border: 1px solid var(--border);
446+
background: var(--surface-alt);
447+
color: var(--text-primary);
448+
display: inline-block;
449+
font-size: 0.95rem;
450+
}
451+
452+
.command-block code {
453+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
454+
font-weight: 700;
455+
}
456+
430457
.byline {
431458
display: inline-flex;
432459
align-items: baseline;

OpenVoting.Client/src/components/AuthPrompt.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function AuthPrompt({ onLogin, loginCta = 'Sign in', loginDisabled = fals
1010
<p className="eyebrow">Sign-in required</p>
1111
<h2>Please log in to continue</h2>
1212
<p className="muted">You need to be a member of the Discord server to view polls and vote. Sign in with Discord to continue</p>
13+
<p className="muted">You can also run <code>/voting</code> anywhere in the server to get a one-time login link</p>
1314
{onLogin && (
1415
<div className="actions">
1516
<button className="primary" disabled={loginDisabled} onClick={onLogin}>{loginCta}</button>

OpenVoting.Client/src/components/DiscordLinkPage.tsx

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useMemo, useState } from 'react';
1+
import { useEffect, useMemo, useState } from 'react';
22
import { useNavigate, useSearchParams } from 'react-router-dom';
3-
import type { OneTimeDiscordLinkAuthResponse } from '../types';
3+
import type { FlashMessage, OneTimeDiscordLinkAuthResponse, OneTimeDiscordLinkStatusResponse } from '../types';
44

55
const tokenStorageKey = 'ov_token';
66

@@ -10,18 +10,91 @@ export function DiscordLinkPage() {
1010
const token = params.get('token') ?? '';
1111
const [submitting, setSubmitting] = useState(false);
1212
const [error, setError] = useState<string | null>(null);
13+
const [statusLoading, setStatusLoading] = useState(false);
14+
const [status, setStatus] = useState<OneTimeDiscordLinkStatusResponse | null>(null);
1315

1416
const hasToken = token.trim().length > 0;
15-
const description = useMemo(() => {
17+
const canContinue = hasToken && status?.status === 'valid';
18+
19+
useEffect(() => {
20+
if (!hasToken) {
21+
setStatus({ status: 'invalid', message: 'Missing login token' });
22+
return;
23+
}
24+
25+
let cancelled = false;
26+
const fetchStatus = async () => {
27+
setStatusLoading(true);
28+
try {
29+
const response = await fetch(`/api/auth/discord-link/status?token=${encodeURIComponent(token)}`);
30+
if (!response.ok) {
31+
const text = await response.text();
32+
throw new Error(text || 'Unable to verify login link');
33+
}
34+
35+
const payload: OneTimeDiscordLinkStatusResponse = await response.json();
36+
if (!cancelled) {
37+
setStatus(payload);
38+
}
39+
} catch (err) {
40+
if (!cancelled) {
41+
const message = err instanceof Error ? err.message : 'Unable to verify login link';
42+
setStatus({ status: 'invalid', message });
43+
}
44+
} finally {
45+
if (!cancelled) {
46+
setStatusLoading(false);
47+
}
48+
}
49+
};
50+
51+
fetchStatus();
52+
return () => {
53+
cancelled = true;
54+
};
55+
}, [hasToken, token]);
56+
57+
const title = useMemo(() => {
1658
if (!hasToken) {
17-
return 'This login link is missing a token';
59+
return 'Do you want to log in?';
60+
}
61+
62+
if (statusLoading) {
63+
return 'Checking login link…';
64+
}
65+
66+
if (status?.displayName) {
67+
return `Do you want to log in as ${status.displayName}?`;
68+
}
69+
70+
return 'Do you want to log in?';
71+
}, [hasToken, statusLoading, status]);
72+
73+
const warningMessage = useMemo<FlashMessage | null>(() => {
74+
if (status?.status === 'used') {
75+
return {
76+
text: `${status.message ?? 'This login link has already been used'}. Run this in the Discord server for a new link:`,
77+
code: '/voting'
78+
};
79+
}
80+
81+
if (status && status.status !== 'valid' && status.message) {
82+
return status.message;
1883
}
1984

20-
return 'This one-time link signs you in to OpenVoting';
21-
}, [hasToken]);
85+
return null;
86+
}, [status]);
87+
88+
useEffect(() => {
89+
window.dispatchEvent(new CustomEvent<FlashMessage | null>('ov-flash', { detail: warningMessage }));
90+
91+
return () => {
92+
window.dispatchEvent(new CustomEvent<FlashMessage | null>('ov-flash', { detail: null }));
93+
};
94+
}, [warningMessage]);
2295

2396
const handleContinue = async () => {
24-
if (!hasToken || submitting) {
97+
if (!canContinue || submitting) {
2598
return;
2699
}
27100

@@ -56,11 +129,10 @@ export function DiscordLinkPage() {
56129
return (
57130
<section className="card splash">
58131
<p className="eyebrow">Discord sign in</p>
59-
<h2>Continue sign in</h2>
60-
<p className="muted">{description}</p>
132+
<h2>{title}</h2>
61133
{error && <p className="error" role="alert">{error}</p>}
62134
<div className="actions">
63-
<button className="primary" type="button" onClick={handleContinue} disabled={!hasToken || submitting}>
135+
<button className="primary" type="button" onClick={handleContinue} disabled={!canContinue || statusLoading || submitting}>
64136
{submitting ? 'Signing in…' : 'Continue'}
65137
</button>
66138
<button className="ghost" type="button" onClick={() => navigate('/', { replace: true })}>

OpenVoting.Client/src/components/PageShell.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
11
import type { ReactNode } from 'react';
2+
import type { FlashMessage } from '../types';
23

34
export type PageShellProps = {
45
topbar: ReactNode;
5-
flash: string | null;
6+
flash: FlashMessage | null;
67
configError: string | null;
78
children: ReactNode;
89
};
910

1011
export function PageShell({ topbar, flash, configError, children }: PageShellProps) {
11-
const flashText = flash?.trim();
12+
const flashText = typeof flash === 'string' ? flash.trim() : flash?.text.trim();
13+
14+
const flashContent = typeof flash === 'string'
15+
? flashText
16+
: flash && flashText
17+
? (
18+
<>
19+
{flashText}
20+
{flash.code && (
21+
<>
22+
{' '}
23+
<code>{flash.code}</code>
24+
</>
25+
)}
26+
</>
27+
)
28+
: null;
29+
1230
return (
1331
<div className="page">
1432
{topbar}
1533
{configError && <div className="banner error">{configError}</div>}
16-
{flashText && !configError && <div className="banner warning">{flashText}</div>}
34+
{flashContent && !configError && <div className="banner warning">{flashContent}</div>}
1735
<main className="content">{children}</main>
1836
<footer className="footer">
1937
<span>Powered by OpenVoting · <a href="https://github.com/AmyJeanes/OpenVoting" target="_blank" rel="noreferrer">GitHub</a></span>

OpenVoting.Client/src/hooks/useVotingApp.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import type {
1212
CreatePollForm,
1313
SessionState,
1414
VoteResponse,
15-
VotingBreakdownEntry
15+
VotingBreakdownEntry,
16+
FlashMessage
1617
} from '../types';
1718
import { useToast } from '../components';
1819

@@ -39,7 +40,7 @@ export function useVotingApp() {
3940
const [me, setMe] = useState<MeResponse | null>(null);
4041
const [config, setConfig] = useState<ConfigResponse | null>(null);
4142
const [configError, setConfigError] = useState<string | null>(null);
42-
const [flash, setFlash] = useState<string | null>(null);
43+
const [flash, setFlash] = useState<FlashMessage | null>(null);
4344

4445
const [poll, setPoll] = useState<PollResponse | null>(null);
4546
const [pollError, setPollError] = useState<string | null>(null);
@@ -100,6 +101,30 @@ export function useVotingApp() {
100101
}
101102
}, []);
102103

104+
useEffect(() => {
105+
const handleFlashEvent = (event: Event) => {
106+
const customEvent = event as CustomEvent<FlashMessage | null>;
107+
const message = customEvent.detail;
108+
109+
if (typeof message === 'string') {
110+
setFlash(message.trim().length > 0 ? message : null);
111+
return;
112+
}
113+
114+
if (message && message.text.trim().length > 0) {
115+
setFlash(message);
116+
return;
117+
}
118+
119+
setFlash(null);
120+
};
121+
122+
window.addEventListener('ov-flash', handleFlashEvent);
123+
return () => {
124+
window.removeEventListener('ov-flash', handleFlashEvent);
125+
};
126+
}, []);
127+
103128
useEffect(() => {
104129
if (!token) {
105130
setSessionState('anonymous');

OpenVoting.Client/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,17 @@ export type OneTimeDiscordLinkAuthResponse = {
157157
token: string;
158158
};
159159

160+
export type OneTimeDiscordLinkStatus = 'valid' | 'used' | 'expired' | 'revoked' | 'banned' | 'invalid';
161+
162+
export type OneTimeDiscordLinkStatusResponse = {
163+
status: OneTimeDiscordLinkStatus;
164+
displayName?: string;
165+
message?: string;
166+
};
167+
168+
export type FlashMessage = string | {
169+
text: string;
170+
code?: string;
171+
};
172+
160173
export type SessionState = 'idle' | 'loading' | 'authenticated' | 'anonymous';

OpenVoting.Server.Tests/OneTimeLoginLinkServiceTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,54 @@ public async Task Consume_BannedMember_ReturnsForbiddenError()
112112
});
113113
}
114114

115+
[Test]
116+
public async Task GetStatusAsync_UsedToken_ReturnsUsed()
117+
{
118+
using var db = TestDbContextFactory.CreateContext(useSqlite: true);
119+
var service = CreateService(db);
120+
121+
var issue = await service.IssueForDiscordUserAsync(
122+
new DiscordInteractionUserContext("user-5", "UserFive", "User Five", [], null),
123+
"https://vote.example.com",
124+
CancellationToken.None);
125+
126+
var token = ExtractToken(issue.LoginLink);
127+
_ = await service.ConsumeAsync(token, CancellationToken.None);
128+
129+
var status = await service.GetStatusAsync(token, CancellationToken.None);
130+
Assert.Multiple(() =>
131+
{
132+
Assert.That(status.Status, Is.EqualTo(OneTimeLoginLinkStatus.Used));
133+
Assert.That(status.DisplayName, Is.EqualTo("User Five"));
134+
});
135+
}
136+
137+
[Test]
138+
public async Task CleanupStaleAsync_RemovesOldExpiredRows()
139+
{
140+
using var db = TestDbContextFactory.CreateContext(useSqlite: true);
141+
var service = CreateService(db);
142+
143+
var issue = await service.IssueForDiscordUserAsync(
144+
new DiscordInteractionUserContext("user-6", "UserSix", null, [], null),
145+
"https://vote.example.com",
146+
CancellationToken.None);
147+
148+
var tokenHash = GetTokenHash(db);
149+
var entity = await db.OneTimeLoginTokens.SingleAsync(t => t.TokenHash == tokenHash, CancellationToken.None);
150+
entity.ExpiresAt = DateTimeOffset.UtcNow.AddDays(-2);
151+
await db.SaveChangesAsync(CancellationToken.None);
152+
153+
var deletedCount = await service.CleanupStaleAsync(CancellationToken.None);
154+
var remaining = await db.OneTimeLoginTokens.CountAsync(CancellationToken.None);
155+
156+
Assert.Multiple(() =>
157+
{
158+
Assert.That(deletedCount, Is.EqualTo(1));
159+
Assert.That(remaining, Is.EqualTo(0));
160+
});
161+
}
162+
115163
private static OneTimeLoginLinkService CreateService(ApplicationDbContext db, string[]? adminRoleIds = null)
116164
{
117165
var settings = Options.Create(new Settings

0 commit comments

Comments
 (0)