-
Notifications
You must be signed in to change notification settings - Fork 578
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
393e365
commit 87c2286
Showing
18 changed files
with
454 additions
and
434 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,137 +1,228 @@ | ||
import { useMemo, useState } from 'react' | ||
|
||
import type { AuthorizeData } from './backend-data' | ||
import type { AuthorizeData, BrandingData } from './backend-data' | ||
import { cookies } from './cookies' | ||
import { Account, Session } from './types' | ||
|
||
import { AcceptPage } from './pages/accept-page' | ||
import { AcceptForm } from './components/accept-form' | ||
import { AccountPicker } from './components/account-picker' | ||
import { PageLayout } from './components/page-layout' | ||
import { SessionSelectionPage } from './pages/session-selector-page' | ||
|
||
export function App({ | ||
requestUri, | ||
clientId, | ||
clientMetadata, | ||
csrfCookie, | ||
loginHint, | ||
sessions: initialSessions, | ||
}: AuthorizeData) { | ||
import { SignInForm, SignInFormOutput } from './components/sign-in-form' | ||
import { Api, SignInResponse } from './lib/api' | ||
|
||
type AppProps = { | ||
authorizeData: AuthorizeData | ||
brandingData?: BrandingData | ||
} | ||
|
||
// TODO: show brandingData when "flow" is null | ||
|
||
export function App({ authorizeData }: AppProps) { | ||
const { | ||
requestUri, | ||
clientId, | ||
clientMetadata, | ||
csrfCookie, | ||
loginHint, | ||
sessions: initialSessions, | ||
} = authorizeData | ||
|
||
const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) | ||
const [isDone, setIsDone] = useState(false) | ||
// const [flow, setFlow] = useState<null | 'sign-in' | 'sign-up'>( | ||
// loginHint != null ? 'sign-in' : null, | ||
// ) | ||
const [sessions, setSessions] = useState(initialSessions) | ||
const [selectedSession, onSession] = useState<Session | null>(null) | ||
|
||
const updateSession = useMemo(() => { | ||
return (account: Account, consentRequired: boolean): Session => { | ||
const sessionIdx = sessions.findIndex( | ||
(s) => s.account.sub === account.sub, | ||
) | ||
if (sessionIdx === -1) { | ||
const newSession: Session = { | ||
initiallySelected: false, | ||
account, | ||
loginRequired: false, | ||
consentRequired, | ||
} | ||
setSessions([...sessions, newSession]) | ||
return newSession | ||
} else { | ||
const curSession = sessions[sessionIdx] | ||
const newSession: Session = { | ||
...curSession, | ||
initiallySelected: false, | ||
account, | ||
consentRequired, | ||
loginRequired: false, | ||
} | ||
setSessions([ | ||
...sessions.slice(0, sessionIdx), | ||
newSession, | ||
...sessions.slice(sessionIdx + 1), | ||
]) | ||
return newSession | ||
} | ||
} | ||
}, [sessions, setSessions]) | ||
|
||
const onLogin = async (credentials: { | ||
username: string | ||
password: string | ||
remember: boolean | ||
}) => { | ||
const r = await fetch('/oauth/authorize/login', { | ||
method: 'POST', | ||
headers: { 'Content-Type': 'application/json' }, | ||
mode: 'same-origin', | ||
body: JSON.stringify({ | ||
csrf_token: csrfToken, | ||
request_uri: requestUri, | ||
client_id: clientId, | ||
credentials, | ||
}), | ||
}) | ||
const json = await r.json() | ||
if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) | ||
|
||
const { account, info } = json | ||
const consentRequired = !info.authorizedClients.includes(clientId) | ||
const session = updateSession(account, consentRequired) | ||
onSession(session) | ||
} | ||
const accounts = useMemo(() => sessions.map((s) => s.account), [sessions]) | ||
const [showSignInForm, setShowSignInForm] = useState(sessions.length === 0) | ||
const [sub, setSub] = useState( | ||
sessions.find((s) => s.initiallySelected)?.account.sub || null, | ||
) | ||
const clearSub = () => setSub(null) | ||
|
||
const authorizeAccept = async (account: Account) => { | ||
setIsDone(true) | ||
const session = useMemo(() => { | ||
return sub ? sessions.find((s) => s.account.sub == sub) : undefined | ||
}, [sub, sessions]) | ||
|
||
const url = new URL('/oauth/authorize/accept', window.origin) | ||
url.searchParams.set('request_uri', requestUri) | ||
url.searchParams.set('account_sub', account.sub) | ||
url.searchParams.set('client_id', clientId) | ||
url.searchParams.set('csrf_token', csrfToken) | ||
const api = useMemo( | ||
() => new Api(requestUri, clientId, csrfToken), | ||
[requestUri, clientId, csrfToken], | ||
) | ||
|
||
const handleSignIn = async (credentials: SignInFormOutput) => { | ||
const { account, info } = await api.signIn(credentials) | ||
|
||
const newSessions = updateSessions(sessions, { account, info }, clientId) | ||
|
||
window.location.href = url.href | ||
setSessions(newSessions) | ||
setSub(account.sub) | ||
} | ||
|
||
const authorizeReject = () => { | ||
const handleAccept = async (account: Account) => { | ||
setIsDone(true) | ||
api.accept(account) | ||
} | ||
|
||
const url = new URL('/oauth/authorize/reject', window.origin) | ||
url.searchParams.set('request_uri', requestUri) | ||
url.searchParams.set('client_id', clientId) | ||
url.searchParams.set('csrf_token', csrfToken) | ||
|
||
window.location.href = url.href | ||
const handleReject = () => { | ||
setIsDone(true) | ||
api.reject() | ||
} | ||
|
||
if (isDone) { | ||
// TODO | ||
return ( | ||
<PageLayout title="Login complete">You are being redirected</PageLayout> | ||
) | ||
} | ||
|
||
if (selectedSession) { | ||
// if (!flow) { | ||
// return ( | ||
// <PageLayout title="Sign in as..."> | ||
// <button onClick={() => setFlow('sign-in')}>Sign in</button> | ||
// <button onClick={() => setFlow('sign-up')}>Sign up</button> | ||
// <button onClick={handleReject}>Abort</button> | ||
// </PageLayout> | ||
// ) | ||
// } | ||
|
||
if (session && !session.loginRequired) { | ||
const { account } = session | ||
return ( | ||
<AcceptPage | ||
onBack={() => onSession(null)} | ||
onAccept={() => authorizeAccept(selectedSession.account)} | ||
onReject={() => authorizeReject()} | ||
account={selectedSession.account} | ||
clientId={clientId} | ||
clientMetadata={clientMetadata} | ||
/> | ||
<PageLayout | ||
title="Authorize" | ||
subtitle={ | ||
<> | ||
Grant access to your{' '} | ||
<b>{account.preferred_username || account.email || account.sub}</b>{' '} | ||
account. | ||
</> | ||
} | ||
> | ||
<AcceptForm | ||
className="max-w-lg w-full" | ||
clientId={clientId} | ||
clientMetadata={clientMetadata} | ||
account={account} | ||
onBack={clearSub} | ||
onAccept={() => handleAccept(account)} | ||
onReject={handleReject} | ||
/> | ||
</PageLayout> | ||
) | ||
} | ||
|
||
if (session && session.loginRequired) { | ||
return ( | ||
<PageLayout title="Sign in" subtitle="Confirm your password to continue"> | ||
<SignInForm | ||
className="max-w-lg w-full" | ||
remember={true} | ||
username={session.account.preferred_username} | ||
usernameReadonly={true} | ||
onSubmit={handleSignIn} | ||
onCancel={clearSub} | ||
cancelLabel={'Back' /* to account picker */} | ||
/> | ||
</PageLayout> | ||
) | ||
} | ||
|
||
if (loginHint) { | ||
return ( | ||
<PageLayout title="Sign in" subtitle="Enter your password"> | ||
<SignInForm | ||
className="max-w-lg w-full" | ||
username={loginHint} | ||
usernameReadonly={true} | ||
onSubmit={handleSignIn} | ||
onCancel={handleReject} | ||
cancelLabel="Back" | ||
/> | ||
</PageLayout> | ||
) | ||
} | ||
|
||
if (sessions.length === 0) { | ||
return ( | ||
<PageLayout title="Sign in" subtitle="Enter your username and password"> | ||
<SignInForm | ||
className="max-w-lg w-full" | ||
onSubmit={handleSignIn} | ||
onCancel={handleReject} | ||
cancelLabel="Back" | ||
/> | ||
</PageLayout> | ||
) | ||
} | ||
|
||
if (showSignInForm) { | ||
return ( | ||
<PageLayout title="Sign in" subtitle="Enter your username and password"> | ||
<SignInForm | ||
className="max-w-lg w-full" | ||
onSubmit={handleSignIn} | ||
onCancel={() => setShowSignInForm(false)} | ||
cancelLabel={'Back' /* to account picker */} | ||
/> | ||
</PageLayout> | ||
) | ||
} | ||
|
||
return ( | ||
<SessionSelectionPage | ||
clientId={clientId} | ||
clientMetadata={clientMetadata} | ||
loginHint={loginHint} | ||
sessions={sessions} | ||
onLogin={onLogin} | ||
onSession={onSession} | ||
onBack={() => authorizeReject()} | ||
backLabel="Deny access" | ||
/> | ||
<PageLayout | ||
title="Sign in as..." | ||
subtitle={ | ||
<> | ||
Select an account to access to{' '} | ||
<b> | ||
{clientMetadata.client_name || | ||
clientMetadata.client_uri || | ||
clientId} | ||
</b> | ||
. | ||
</> | ||
} | ||
> | ||
<AccountPicker | ||
className="max-w-lg w-full" | ||
accounts={accounts} | ||
onAccount={(a) => setSub(a.sub)} | ||
onOther={() => setShowSignInForm(true)} | ||
onBack={handleReject} | ||
backLabel="Back" | ||
/> | ||
</PageLayout> | ||
) | ||
} | ||
|
||
function updateSessions( | ||
sessions: readonly Session[], | ||
{ account, info }: SignInResponse, | ||
clientId: string, | ||
): Session[] { | ||
const consentRequired = !info.authorizedClients.includes(clientId) | ||
|
||
const sessionIdx = sessions.findIndex((s) => s.account.sub === account.sub) | ||
if (sessionIdx === -1) { | ||
const newSession: Session = { | ||
initiallySelected: false, | ||
account, | ||
loginRequired: false, | ||
consentRequired, | ||
} | ||
return [...sessions, newSession] | ||
} else { | ||
const curSession = sessions[sessionIdx] | ||
const newSession: Session = { | ||
...curSession, | ||
initiallySelected: false, | ||
account, | ||
consentRequired, | ||
loginRequired: false, | ||
} | ||
return [ | ||
...sessions.slice(0, sessionIdx), | ||
newSession, | ||
...sessions.slice(sessionIdx + 1), | ||
] | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.