Finish Phase 6: Push frontend, WebAuthn frontend, OpenAPI/Swagger
Frontend: - stores/push.ts: fixed VAPID key endpoint, added checkSubscription, proper subscription format - UserSettings.tsx: added [ENABLE PUSH] toggle and [REGISTER PASSKEY] button - LoginForm.tsx: added [SIGN IN WITH PASSKEY] button Backend: - cmd/server/main.go: added Swagger annotations, /docs/* route - docs/: generated swagger.json, swagger.yaml, docs.go - Makefile: added docs target for swag init
This commit is contained in:
@@ -85,6 +85,42 @@ export function LoginForm() {
|
||||
{isLoading ? '[PROCESSING...]' : isRegister ? '[REGISTER]' : '[LOGIN]'}
|
||||
</button>
|
||||
</form>
|
||||
{!isRegister && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Get challenge from server
|
||||
const resp = await fetch('/api/v1/auth/webauthn/login/begin', { method: 'POST', credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Passkey login not available');
|
||||
const options = await resp.json();
|
||||
|
||||
// Use WebAuthn API
|
||||
const credential = await navigator.credentials.get({ publicKey: options });
|
||||
if (!credential) return;
|
||||
|
||||
// Send to server
|
||||
const finishResp = await fetch('/api/v1/auth/webauthn/login/finish', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credential),
|
||||
});
|
||||
|
||||
if (finishResp.ok) {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Passkey login failed:', err);
|
||||
}
|
||||
}}
|
||||
className="terminal-button w-full border-gb-aqua text-gb-aqua"
|
||||
>
|
||||
[SIGN IN WITH PASSKEY]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
import { usePushStore } from '../stores/push.ts';
|
||||
|
||||
export function UserSettings() {
|
||||
const { user, updateProfile, isLoading, error, clearError, fetchMe } = useAuthStore();
|
||||
const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
@@ -22,7 +24,8 @@ export function UserSettings() {
|
||||
setStatusText(user.status_text || '');
|
||||
setAccentColor(user.accent_color || '#fe8019');
|
||||
}
|
||||
}, [user]);
|
||||
checkSubscription();
|
||||
}, [user, checkSubscription]);
|
||||
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
@@ -231,6 +234,70 @@ export function UserSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Push Notifications */}
|
||||
{isSupported && (
|
||||
<div className="border border-gb-bg-t p-4">
|
||||
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
||||
NOTIFICATIONS:
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={isSubscribed ? unsubscribe : subscribe}
|
||||
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
|
||||
>
|
||||
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
|
||||
</button>
|
||||
<span className="text-gb-fg-f text-xs font-mono">
|
||||
{isSubscribed ? '● subscribed' : '○ not subscribed'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
||||
Receive push notifications for @mentions and DMs when the app is closed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passkey Registration */}
|
||||
<div className="border border-gb-bg-t p-4">
|
||||
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
||||
PASSKEYS:
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/auth/webauthn/register/begin', { method: 'POST', credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Passkey registration not available');
|
||||
const options = await resp.json();
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey: options });
|
||||
if (!credential) return;
|
||||
|
||||
const finishResp = await fetch('/api/v1/auth/webauthn/register/finish', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credential),
|
||||
});
|
||||
|
||||
if (finishResp.ok) {
|
||||
alert('Passkey registered successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Passkey registration failed:', err);
|
||||
alert('Passkey registration not yet available on this server.');
|
||||
}
|
||||
}}
|
||||
className="terminal-button text-xs border-gb-aqua text-gb-aqua"
|
||||
>
|
||||
[REGISTER PASSKEY]
|
||||
</button>
|
||||
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
||||
Use biometrics or security key for passwordless login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Errors & Success */}
|
||||
{error && (
|
||||
<p className="text-gb-red text-sm font-mono">ERR: {error}</p>
|
||||
|
||||
+27
-3
@@ -8,6 +8,7 @@ interface PushState {
|
||||
subscribe: () => Promise<void>;
|
||||
unsubscribe: () => Promise<void>;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
checkSubscription: () => Promise<void>;
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): BufferSource {
|
||||
@@ -24,7 +25,7 @@ function urlBase64ToUint8Array(base64String: string): BufferSource {
|
||||
export const usePushStore = create<PushState>((set, get) => ({
|
||||
isSupported: 'serviceWorker' in navigator && 'PushManager' in window,
|
||||
isSubscribed: false,
|
||||
permission: 'default',
|
||||
permission: typeof Notification !== 'undefined' ? Notification.permission : 'default',
|
||||
|
||||
requestPermission: async () => {
|
||||
if (!get().isSupported) return 'denied';
|
||||
@@ -33,16 +34,31 @@ export const usePushStore = create<PushState>((set, get) => ({
|
||||
return permission;
|
||||
},
|
||||
|
||||
checkSubscription: async () => {
|
||||
if (!get().isSupported) return;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
set({ isSubscribed: !!subscription });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
subscribe: async () => {
|
||||
if (!get().isSupported) return;
|
||||
|
||||
try {
|
||||
// Request notification permission first
|
||||
const permission = await get().requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
|
||||
// Register service worker
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Get VAPID public key from server
|
||||
const { publicKey } = await api.get<{ publicKey: string }>('/push/vapid-key');
|
||||
const { publicKey } = await api.get<{ publicKey: string }>('/push/vapid-public-key');
|
||||
if (!publicKey) return;
|
||||
|
||||
// Subscribe to push
|
||||
@@ -51,8 +67,16 @@ export const usePushStore = create<PushState>((set, get) => ({
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
|
||||
const sub = subscription.toJSON();
|
||||
const keys = sub.keys as { p256dh: string; auth: string };
|
||||
|
||||
// Send subscription to server
|
||||
await api.post('/push/subscribe', subscription.toJSON());
|
||||
await api.post('/push/subscribe', {
|
||||
endpoint: sub.endpoint!,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
});
|
||||
|
||||
set({ isSubscribed: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe to push:', error);
|
||||
|
||||
Reference in New Issue
Block a user