Add ModeTemplate with Svelte 5 frontend and components

- Create reusable components: auth, api-client, shared-types
- ModeTemplate with Hono backend and SvelteKit frontend
- Auth store refactored to class-based Svelte 5 pattern
- SSR-safe hydrate() pattern for localStorage access
- Frontend builds successfully with Svelte 5 runes
This commit is contained in:
2026-01-21 05:12:25 +00:00
parent bd7842799c
commit 05f8e2e3b6
120 changed files with 12252 additions and 0 deletions
@@ -0,0 +1,28 @@
export {
getAuthUrl,
handleCallback,
exchangeCodeForToken,
getMicrosoftUserInfo,
getPocketBaseToken,
getConfigFromEnv,
} from './microsoft-oauth';
export type {
AuthResult,
OAuthConfig
} from './microsoft-oauth';
export {
HEADERS,
CONTENT_TYPES,
STORAGE_KEYS,
pbAuthHeader,
pbHeaders,
isSessionExpired,
} from './types';
export type {
MicrosoftTokens,
PocketBaseTokens,
AuthSession,
} from './types';
@@ -0,0 +1,208 @@
/**
* Microsoft OAuth Module
*
* Handles Microsoft OAuth flow to authenticate users and retrieve:
* - User's email
* - User's display name
* - PocketBase auth token (user must already exist)
*
* Environment Variables Used:
* - MICROSOFT_CLIENT_ID
* - MICROSOFT_CLIENT_SECRET
* - MICROSOFT_TENANT_ID
* - MICROSOFT_REDIRECT_URI
* - MICROSOFT_SCOPES
* - PB_URL
*/
interface MicrosoftTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
refresh_token?: string;
}
interface MicrosoftUserInfo {
id: string;
displayName: string;
mail: string;
userPrincipalName: string;
}
export interface AuthResult {
email: string;
displayName: string;
pocketbaseToken: string;
}
export interface OAuthConfig {
clientId: string;
clientSecret: string;
tenantId: string;
redirectUri: string;
scopes: string;
pbUrl: string;
}
/**
* Build config from process.env
*/
export function getConfigFromEnv(): OAuthConfig {
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
const tenantId = process.env.MICROSOFT_TENANT_ID;
const redirectUri = process.env.MICROSOFT_REDIRECT_URI;
const scopes = process.env.MICROSOFT_SCOPES;
const pbUrl = process.env.PB_URL;
if (!clientId || !clientSecret || !tenantId || !redirectUri || !scopes || !pbUrl) {
throw new Error('Missing required OAuth environment variables');
}
return { clientId, clientSecret, tenantId, redirectUri, scopes, pbUrl };
}
/**
* Generate the Microsoft OAuth authorization URL
*/
export function getAuthUrl(config: OAuthConfig): string {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: 'code',
redirect_uri: config.redirectUri,
response_mode: 'query',
scope: config.scopes,
state: crypto.randomUUID(),
});
return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize?${params}`;
}
/**
* Exchange authorization code for access token
*/
export async function exchangeCodeForToken(
code: string,
config: OAuthConfig
): Promise<MicrosoftTokenResponse> {
const response = await fetch(
`https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
code,
redirect_uri: config.redirectUri,
grant_type: 'authorization_code',
scope: config.scopes,
}),
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return response.json();
}
/**
* Get user info from Microsoft Graph API
*/
export async function getMicrosoftUserInfo(
accessToken: string
): Promise<MicrosoftUserInfo> {
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to get user info: ${error}`);
}
return response.json();
}
/**
* Authenticate existing user with PocketBase
* User MUST already exist - no user creation
*/
export async function getPocketBaseToken(
email: string,
pbUrl: string
): Promise<string> {
// Look up user by email - user must exist
const searchResponse = await fetch(
`${pbUrl}/api/collections/users/records?filter=(email='${encodeURIComponent(email)}')`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
if (!searchResponse.ok) {
throw new Error('Failed to query PocketBase');
}
const searchData = await searchResponse.json();
if (!searchData.items || searchData.items.length === 0) {
throw new Error('Access denied: User does not exist');
}
// User exists - authenticate via OAuth
const authResponse = await fetch(`${pbUrl}/api/collections/users/auth-with-oauth2`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: 'microsoft',
}),
});
if (!authResponse.ok) {
const error = await authResponse.text();
throw new Error(`PocketBase auth failed: ${error}`);
}
const data = await authResponse.json();
return data.token;
}
/**
* Complete OAuth flow: code -> user info + PocketBase token
* Denies access if user doesn't exist in PocketBase
*/
export async function handleCallback(
code: string,
config: OAuthConfig
): Promise<AuthResult> {
// 1. Exchange code for Microsoft access token
const tokenResponse = await exchangeCodeForToken(code, config);
// 2. Get user info from Microsoft Graph
const userInfo = await getMicrosoftUserInfo(tokenResponse.access_token);
const email = userInfo.mail || userInfo.userPrincipalName;
const displayName = userInfo.displayName;
// 3. Get PocketBase auth token (user must exist)
const pocketbaseToken = await getPocketBaseToken(email, config.pbUrl);
return {
email,
displayName,
pocketbaseToken,
};
}
@@ -0,0 +1,99 @@
/**
* Auth Constants & Types
*
* Standard naming conventions for auth-related values.
* Use these throughout all modes to maintain consistency.
*/
// ============================================
// TOKEN TYPES
// ============================================
/** Token received from Microsoft OAuth */
export interface MicrosoftTokens {
accessToken: string;
refreshToken?: string;
expiresIn: number;
}
/** Token received from PocketBase */
export interface PocketBaseTokens {
token: string;
// PB tokens don't have separate refresh - they expire and re-auth is needed
}
/** Combined auth state after successful login */
export interface AuthSession {
email: string;
displayName: string;
pbToken: string; // PocketBase auth token
msAccessToken?: string; // Microsoft access token (if needed for Graph API calls)
expiresAt?: number; // Unix timestamp when session expires
}
// ============================================
// HEADER NAMES
// ============================================
export const HEADERS = {
/** Authorization header for PocketBase API calls */
PB_AUTH: 'Authorization',
/** Content type for JSON requests */
CONTENT_TYPE: 'Content-Type',
} as const;
// ============================================
// HEADER VALUES
// ============================================
export const CONTENT_TYPES = {
JSON: 'application/json',
FORM: 'application/x-www-form-urlencoded',
} as const;
// ============================================
// COOKIE/STORAGE KEYS
// ============================================
export const STORAGE_KEYS = {
/** PocketBase token storage key */
PB_TOKEN: 'pb_token',
/** Session data storage key */
SESSION: 'auth_session',
} as const;
// ============================================
// HELPER FUNCTIONS
// ============================================
/**
* Create Authorization header value for PocketBase
*/
export function pbAuthHeader(token: string): string {
return `Bearer ${token}`;
}
/**
* Create headers object for PocketBase API calls
*/
export function pbHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = {
[HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON,
};
if (token) {
headers[HEADERS.PB_AUTH] = pbAuthHeader(token);
}
return headers;
}
/**
* Check if session is expired
*/
export function isSessionExpired(session: AuthSession): boolean {
if (!session.expiresAt) return false;
return Date.now() > session.expiresAt;
}