feat(ui): wire resolvespec oauth login flow
Some checks failed
CI / build-and-test (push) Failing after -32m3s
Some checks failed
CI / build-and-test (push) Failing after -32m3s
This commit is contained in:
@@ -1,19 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getApiURL } from '@warkypublic/svelix';
|
||||
import {
|
||||
Former,
|
||||
FormerRestHeadSpecAPI,
|
||||
InlineWrapper,
|
||||
PasswordInputCtrl,
|
||||
TextInputCtrl,
|
||||
getApiURL
|
||||
} from '@warkypublic/svelix';
|
||||
import { ensureApiURL, GlobalStateStore } from './shellState';
|
||||
|
||||
type LoginResult = {
|
||||
token?: string;
|
||||
username?: string;
|
||||
};
|
||||
buildOAuthAuthorizationURL,
|
||||
ensureApiURL,
|
||||
exchangeOAuthCode,
|
||||
GlobalStateStore,
|
||||
setCurrentPath
|
||||
} from './shellState';
|
||||
|
||||
type AccessEntry = {
|
||||
key_id: string;
|
||||
@@ -68,12 +62,10 @@
|
||||
}
|
||||
];
|
||||
|
||||
let values = $state<{ username: string; password: string }>({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
let authMessage = $state('');
|
||||
let authError = $state('');
|
||||
let authBusy = $state(false);
|
||||
let callbackBusy = $state(false);
|
||||
let data = $state<StatusResponse | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
@@ -81,29 +73,57 @@
|
||||
|
||||
ensureApiURL(import.meta.env.VITE_API_URL);
|
||||
|
||||
const onAPICall = $derived(
|
||||
FormerRestHeadSpecAPI({
|
||||
url: `${getApiURL()}/login`
|
||||
})
|
||||
);
|
||||
|
||||
const isLoggedIn = $derived(GlobalStateStore.isLoggedIn());
|
||||
const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/');
|
||||
const isOAuthCallback = $derived(currentPath === '/oauth/callback');
|
||||
const oauthAuthorizeURL = $derived(`${getApiURL()}/oauth/authorize`);
|
||||
|
||||
async function handleLogin(value: LoginResult): Promise<void> {
|
||||
const token = value?.token?.trim();
|
||||
if (!token) {
|
||||
authError = 'Login succeeded but no token was returned by the API.';
|
||||
authMessage = '';
|
||||
return;
|
||||
}
|
||||
|
||||
await GlobalStateStore.getState().login(token, {
|
||||
username: value?.username ?? values.username
|
||||
});
|
||||
|
||||
authMessage = `Welcome back ${value?.username ?? values.username ?? 'operator'}.`;
|
||||
async function startOAuthLogin(): Promise<void> {
|
||||
authBusy = true;
|
||||
authError = '';
|
||||
await loadStatus();
|
||||
authMessage = '';
|
||||
|
||||
try {
|
||||
const authorizationURL = await buildOAuthAuthorizationURL();
|
||||
window.location.assign(authorizationURL);
|
||||
} catch (err) {
|
||||
authError = err instanceof Error ? err.message : 'Failed to start OAuth login.';
|
||||
} finally {
|
||||
authBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishOAuthLogin(): Promise<void> {
|
||||
callbackBusy = true;
|
||||
authError = '';
|
||||
authMessage = '';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const returnedState = params.get('state');
|
||||
const oauthError = params.get('error');
|
||||
|
||||
if (oauthError) {
|
||||
throw new Error(`OAuth login failed: ${oauthError}`);
|
||||
}
|
||||
if (!code || !returnedState) {
|
||||
throw new Error('OAuth callback is missing code or state.');
|
||||
}
|
||||
|
||||
const token = await exchangeOAuthCode(code, returnedState);
|
||||
await GlobalStateStore.getState().login(token, {
|
||||
username: 'OAuth operator'
|
||||
});
|
||||
|
||||
authMessage = 'OAuth login complete. Welcome back.';
|
||||
window.history.replaceState({}, '', '/');
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
authError = err instanceof Error ? err.message : 'OAuth callback failed.';
|
||||
} finally {
|
||||
callbackBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
@@ -134,6 +154,15 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setCurrentPath(window.location.pathname);
|
||||
}
|
||||
|
||||
if (isOAuthCallback) {
|
||||
await finishOAuthLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
await loadStatus();
|
||||
}
|
||||
@@ -153,10 +182,16 @@
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||
AMCS Control Interface
|
||||
</div>
|
||||
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">Login</h1>
|
||||
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">
|
||||
{#if isOAuthCallback}
|
||||
Completing login
|
||||
{:else}
|
||||
Login
|
||||
{/if}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300">
|
||||
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth stays the auth brain;
|
||||
this shell just gives us the front door.
|
||||
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth is the front door now,
|
||||
not the old login shortcut.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
@@ -166,66 +201,63 @@
|
||||
<p class="mt-2 text-sm text-slate-400">Projects are the first real admin screen in this rollout.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">UI direction</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">Origin-like</p>
|
||||
<p class="mt-2 text-sm text-slate-400">Login and page structure mapped toward Origin patterns.</p>
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">OAuth path</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">ResolveSpec</p>
|
||||
<p class="mt-2 text-sm text-slate-400">Client registration, authorize, callback, token exchange.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8">
|
||||
<h2 class="text-xl font-semibold text-white">Operator login</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Authenticate to access AMCS admin pages.</p>
|
||||
{#if isOAuthCallback}
|
||||
<h2 class="text-xl font-semibold text-white">Authorizing operator session</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-400">
|
||||
Finishing the ResolveSpec handshake and exchanging the returned code for an AMCS token.
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<Former
|
||||
bind:values
|
||||
layout={{ buttonArea: 'none' }}
|
||||
{onAPICall}
|
||||
onChange={(value) => {
|
||||
handleLogin(value as LoginResult);
|
||||
}}
|
||||
onError={(err) => {
|
||||
authError = String(err || 'Failed to login. Please check your credentials and try again.');
|
||||
authMessage = '';
|
||||
}}
|
||||
request="insert"
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4">
|
||||
<InlineWrapper label="Email address" required value={state.values?.username ?? ''}>
|
||||
<TextInputCtrl
|
||||
name="username"
|
||||
placeholder="operator@example.com"
|
||||
value={state.values?.username ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, username: v })}
|
||||
/>
|
||||
</InlineWrapper>
|
||||
|
||||
<InlineWrapper label="Password" required value={state.values?.password ?? ''}>
|
||||
<PasswordInputCtrl
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
value={state.values?.password ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, password: v })}
|
||||
/>
|
||||
</InlineWrapper>
|
||||
|
||||
<button type="submit" class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20">
|
||||
Login
|
||||
</button>
|
||||
|
||||
{#if authError}
|
||||
<p class="text-sm text-rose-300">{authError}</p>
|
||||
{/if}
|
||||
{#if authMessage}
|
||||
<p class="text-sm text-emerald-300">{authMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Former>
|
||||
<div class="mt-6 rounded-2xl border border-cyan-400/20 bg-cyan-400/5 px-4 py-6 text-sm text-cyan-100">
|
||||
{#if callbackBusy}
|
||||
Working the callback doohickey…
|
||||
{:else if authError}
|
||||
Callback failed. Fix the route or try the login run again.
|
||||
{:else}
|
||||
Callback processed.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<h2 class="text-xl font-semibold text-white">Operator login</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Authenticate through AMCS ResolveSpec OAuth endpoints.</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onclick={startOAuthLogin}
|
||||
disabled={authBusy}
|
||||
>
|
||||
{#if authBusy}Starting OAuth login…{:else}Login with ResolveSpec OAuth{/if}
|
||||
</button>
|
||||
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-300">
|
||||
<p class="font-semibold text-white">Routes in play</p>
|
||||
<ul class="mt-3 space-y-2 text-slate-400">
|
||||
<li>• discovery: <code class="text-cyan-100">/api/.well-known/oauth-authorization-server</code></li>
|
||||
<li>• registration: <code class="text-cyan-100">/api/oauth/register</code></li>
|
||||
<li>• authorize: <code class="text-cyan-100">{oauthAuthorizeURL}</code></li>
|
||||
<li>• callback: <code class="text-cyan-100">/oauth/callback</code></li>
|
||||
<li>• token: <code class="text-cyan-100">/api/oauth/token</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{#if authError}
|
||||
<p class="text-sm text-rose-300">{authError}</p>
|
||||
{/if}
|
||||
{#if authMessage}
|
||||
<p class="text-sm text-emerald-300">{authMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user