1- import { useMemo , useState } from 'react' ;
1+ import { useEffect , useMemo , useState } from 'react' ;
22import { useNavigate , useSearchParams } from 'react-router-dom' ;
3- import type { OneTimeDiscordLinkAuthResponse } from '../types' ;
3+ import type { FlashMessage , OneTimeDiscordLinkAuthResponse , OneTimeDiscordLinkStatusResponse } from '../types' ;
44
55const 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 } ) } >
0 commit comments