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
+84
View File
@@ -0,0 +1,84 @@
# Auth Component
Microsoft OAuth authentication that returns user info and a PocketBase token.
**Users must already exist in PocketBase - no user creation. Access denied if user not found.**
## What it does
1. Redirects user to Microsoft login
2. Exchanges auth code for access token
3. Gets user's email and display name from Microsoft Graph
4. Checks if user exists in PocketBase (denies access if not)
5. Returns PocketBase auth token
## Environment Variables Used
These variables exist in `/home/admin/secrets/.env`:
```
MICROSOFT_CLIENT_ID
MICROSOFT_CLIENT_SECRET
MICROSOFT_TENANT_ID
MICROSOFT_REDIRECT_URI
MICROSOFT_SCOPES
PB_URL
```
## Usage in Hono Backend
```typescript
import { Hono } from 'hono';
import { getAuthUrl, handleCallback, getConfigFromEnv } from './components/auth';
const app = new Hono();
const config = getConfigFromEnv();
// Redirect to Microsoft login
app.get('/auth/login', (c) => {
return c.redirect(getAuthUrl(config));
});
// Handle callback from Microsoft
app.get('/auth/callback', async (c) => {
const code = c.req.query('code');
if (!code) {
return c.json({ error: 'No authorization code' }, 400);
}
try {
const result = await handleCallback(code, config);
// result contains:
// - email: string
// - displayName: string
// - pocketbaseToken: string
return c.json(result);
} catch (err) {
// User doesn't exist or auth failed
return c.json({ error: String(err) }, 403);
}
});
```
## Copying to a Mode
1. Copy the entire `components/auth/` folder to your mode's `backend/modules/auth/`
2. Update imports in your server
3. Environment variables are loaded from secrets at runtime
## Dependencies
None - uses native fetch API (Bun/Node 18+). No package.json needed - inherits from consuming project.
## Returns
```typescript
interface AuthResult {
email: string; // User's email from Microsoft
displayName: string; // User's display name from Microsoft
pocketbaseToken: string; // Auth token for PocketBase API calls
}
```
+28
View File
@@ -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';
+208
View File
@@ -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,
};
}
+99
View File
@@ -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;
}