@chesapeake/stack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +190 -0
  3. package/README.md +440 -0
  4. package/components/ui/avatar.tsx +87 -0
  5. package/components/ui/badge.tsx +49 -0
  6. package/components/ui/badgeVariants.ts +26 -0
  7. package/components/ui/button-group.tsx +67 -0
  8. package/components/ui/button.tsx +70 -0
  9. package/components/ui/buttonGroupVariants.ts +23 -0
  10. package/components/ui/buttonVariants.ts +38 -0
  11. package/components/ui/calendar.tsx +220 -0
  12. package/components/ui/card.tsx +162 -0
  13. package/components/ui/dialog.tsx +290 -0
  14. package/components/ui/drawer.tsx +227 -0
  15. package/components/ui/dropdown-menu.tsx +260 -0
  16. package/components/ui/index.ts +86 -0
  17. package/components/ui/input.tsx +59 -0
  18. package/components/ui/label.tsx +47 -0
  19. package/components/ui/location-input.md +194 -0
  20. package/components/ui/location-input.tsx +384 -0
  21. package/components/ui/popover.tsx +110 -0
  22. package/components/ui/select.tsx +306 -0
  23. package/components/ui/separator.tsx +50 -0
  24. package/components/ui/sonner-api.ts +8 -0
  25. package/components/ui/sonner.tsx +69 -0
  26. package/components/ui/textarea.tsx +53 -0
  27. package/components/ui/tooltip.tsx +125 -0
  28. package/components/ux/ActivityAnimation.tsx +135 -0
  29. package/components/ux/EmptyState.tsx +22 -0
  30. package/components/ux/FeedbackDrawer.tsx +141 -0
  31. package/components/ux/FireAnimation.tsx +189 -0
  32. package/components/ux/FriendList.tsx +111 -0
  33. package/components/ux/FriendRequestList.tsx +104 -0
  34. package/components/ux/GifSearch.tsx +264 -0
  35. package/components/ux/ImageUpload.tsx +829 -0
  36. package/components/ux/SelectedUsersList.tsx +91 -0
  37. package/components/ux/StandardLayout/PageLayout.tsx +107 -0
  38. package/components/ux/StandardLayout/ProfilePage.tsx +452 -0
  39. package/components/ux/StandardLayout/StandardLayout.tsx +345 -0
  40. package/components/ux/StandardLayout/index.ts +22 -0
  41. package/components/ux/TutorialCarousel.tsx +128 -0
  42. package/components/ux/UserSearch.tsx +324 -0
  43. package/components/ux/UserSelector.tsx +116 -0
  44. package/components/ux/auth/AuthSelector.tsx +154 -0
  45. package/components/ux/auth/MultiAuthProvider.tsx +196 -0
  46. package/components/ux/auth/README.md +317 -0
  47. package/components/ux/auth/context.ts +46 -0
  48. package/components/ux/auth/github/AuthCallback.tsx +177 -0
  49. package/components/ux/auth/github/AuthProvider.tsx +111 -0
  50. package/components/ux/auth/github/README.md +208 -0
  51. package/components/ux/auth/github/client.ts +127 -0
  52. package/components/ux/auth/github/config.ts +68 -0
  53. package/components/ux/auth/github/context.ts +34 -0
  54. package/components/ux/auth/github/default-client.ts +74 -0
  55. package/components/ux/auth/github/index.ts +43 -0
  56. package/components/ux/auth/github/types.ts +44 -0
  57. package/components/ux/auth/github/useGitHubAuth.ts +16 -0
  58. package/components/ux/auth/google/AuthCallback.tsx +170 -0
  59. package/components/ux/auth/google/AuthProvider.tsx +108 -0
  60. package/components/ux/auth/google/README.md +450 -0
  61. package/components/ux/auth/google/client.ts +109 -0
  62. package/components/ux/auth/google/config.ts +65 -0
  63. package/components/ux/auth/google/context.ts +34 -0
  64. package/components/ux/auth/google/default-client.ts +69 -0
  65. package/components/ux/auth/google/index.ts +43 -0
  66. package/components/ux/auth/google/types.ts +42 -0
  67. package/components/ux/auth/google/useGoogleAuth.ts +16 -0
  68. package/components/ux/auth/index.ts +72 -0
  69. package/components/ux/auth/useAuth.ts +16 -0
  70. package/components/ux/index.ts +46 -0
  71. package/components/ux/theme-provider-context.ts +20 -0
  72. package/components/ux/theme-provider.tsx +114 -0
  73. package/components/ux/useTheme.ts +42 -0
  74. package/index.ts +29 -0
  75. package/lib/utils.ts +57 -0
  76. package/package.json +170 -0
  77. package/styles/base.css +38 -0
  78. package/styles/components.css +54 -0
  79. package/styles/index.css +7 -0
  80. package/styles/tokens.css +189 -0
  81. package/tailwind.preset.ts +169 -0
  82. package/types/user.ts +69 -0
@@ -0,0 +1,177 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+ import { useEffect, useRef } from "react";
6
+ import type { GitHubOAuthProfile } from "./types.ts";
7
+ import { GitHubAuthClient } from "./client.ts";
8
+ import { handleGitHubCallback as defaultHandleCallback } from "./default-client.ts";
9
+ import type { GitHubAuthConfig } from "./config.ts";
10
+
11
+ export interface GitHubAuthCallbackProps {
12
+ /** Authorization code from URL query params */
13
+ code: string;
14
+ /** State parameter from URL query params (optional) */
15
+ state?: string;
16
+ /** Backend API base URL (default: auto-detect) */
17
+ apiBaseUrl?: string;
18
+ /** Callback when user profile is successfully retrieved */
19
+ onSuccess: (profile: GitHubOAuthProfile) => void | Promise<void>;
20
+ /** Callback when an error occurs */
21
+ onError: (error: Error) => void;
22
+ /** Custom GitHub Auth configuration (if not provided, uses default-client) */
23
+ config?: GitHubAuthConfig;
24
+ }
25
+
26
+ /**
27
+ * Configurable GitHub Auth Callback component
28
+ * Handles OAuth callback and calls appropriate callbacks
29
+ */
30
+ export function GitHubAuthCallback({
31
+ code,
32
+ state,
33
+ apiBaseUrl,
34
+ onSuccess,
35
+ onError,
36
+ config,
37
+ }: GitHubAuthCallbackProps) {
38
+ const processedRef = useRef(false);
39
+
40
+ useEffect(() => {
41
+ if (processedRef.current) return;
42
+ processedRef.current = true;
43
+
44
+ const handleCallback = async () => {
45
+ try {
46
+ let profile: GitHubOAuthProfile;
47
+ if (config) {
48
+ const client = new GitHubAuthClient(config);
49
+ profile = await client.handleCallback(code, state);
50
+ } else {
51
+ profile = await defaultHandleCallback(code, state);
52
+ }
53
+ await onSuccess(profile);
54
+ } catch (error) {
55
+ onError(error instanceof Error ? error : new Error(String(error)));
56
+ }
57
+ };
58
+
59
+ void handleCallback();
60
+ }, [code, state, apiBaseUrl, onSuccess, onError, config]);
61
+
62
+ return (
63
+ <div className="flex items-center justify-center min-h-screen bg-background">
64
+ <div className="text-center">
65
+ <h1 className="text-2xl font-bold mb-4">Completing sign in...</h1>
66
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export interface DefaultGitHubAuthCallbackProps {
73
+ /** Authorization code from URL query params */
74
+ code: string;
75
+ /** State parameter from URL query params (optional) */
76
+ state?: string;
77
+ /** Callback when existing user is found */
78
+ onUserFound: (profile: GitHubOAuthProfile) => void | Promise<void>;
79
+ /** Callback when new user (not found in system) */
80
+ onUserNotFound: (profile: GitHubOAuthProfile) => void | Promise<void>;
81
+ /** Callback when an error occurs */
82
+ onError: (error: Error) => void;
83
+ /** Backend API endpoint for user lookup (default: /api/users) */
84
+ userLookupEndpoint?: string;
85
+ /** Redirect path after successful login (default: /home) */
86
+ redirectPath?: string;
87
+ /** Redirect path for new users (default: /create-user) */
88
+ newUserRedirectPath?: string;
89
+ }
90
+
91
+ /**
92
+ * Default GitHub Auth Callback component with convention-over-configuration
93
+ * Handles common flow: callback -> lookup user -> redirect
94
+ */
95
+ export function DefaultGitHubAuthCallback({
96
+ code,
97
+ state,
98
+ onUserFound,
99
+ onUserNotFound,
100
+ onError,
101
+ userLookupEndpoint,
102
+ redirectPath,
103
+ newUserRedirectPath,
104
+ }: DefaultGitHubAuthCallbackProps) {
105
+ const processedRef = useRef(false);
106
+
107
+ useEffect(() => {
108
+ if (processedRef.current) return;
109
+ processedRef.current = true;
110
+
111
+ const handleCallback = async () => {
112
+ try {
113
+ // Get user profile from backend
114
+ const profile = await defaultHandleCallback(code, state);
115
+
116
+ // Look up existing user
117
+ try {
118
+ const response = await fetch(userLookupEndpoint ?? "");
119
+ if (response.ok) {
120
+ const userResponse = (await response.json()) as {
121
+ value?: { githubOAuthId?: number }[];
122
+ };
123
+ const users = userResponse.value ?? [];
124
+ const existingUser = users.find(
125
+ (u: { githubOAuthId?: number }) => u.githubOAuthId === profile.id
126
+ );
127
+
128
+ if (existingUser) {
129
+ await onUserFound(profile);
130
+ // Store redirect path if using localStorage
131
+ const storedRedirect =
132
+ localStorage.getItem("redirectAfterLogin") ?? redirectPath;
133
+ if (storedRedirect) {
134
+ // Redirect logic would go here
135
+ }
136
+ localStorage.removeItem("redirectAfterLogin");
137
+ // Note: Actual navigation should be handled by the app's router
138
+ return;
139
+ }
140
+ }
141
+ } catch (lookupError) {
142
+ console.warn(
143
+ "User lookup failed, treating as new user:",
144
+ lookupError
145
+ );
146
+ }
147
+
148
+ // New user flow
149
+ localStorage.setItem("oauth_profile", JSON.stringify(profile));
150
+ await onUserNotFound(profile);
151
+ // Note: Actual navigation should be handled by the app's router
152
+ } catch (error) {
153
+ onError(error instanceof Error ? error : new Error(String(error)));
154
+ }
155
+ };
156
+
157
+ void handleCallback();
158
+ }, [
159
+ code,
160
+ state,
161
+ onUserFound,
162
+ onUserNotFound,
163
+ onError,
164
+ userLookupEndpoint,
165
+ redirectPath,
166
+ newUserRedirectPath,
167
+ ]);
168
+
169
+ return (
170
+ <div className="flex items-center justify-center min-h-screen bg-background">
171
+ <div className="text-center">
172
+ <h1 className="text-2xl font-bold mb-4">Completing sign in...</h1>
173
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
@@ -0,0 +1,111 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** @jsxImportSource react */
5
+ import React, { useCallback, useEffect, useState } from "react";
6
+ import { GitHubAuthClient } from "./client.ts";
7
+ import { initiateGitHubLogin as defaultInitiateLogin } from "./default-client.ts";
8
+
9
+ import {
10
+ type AuthUser,
11
+ GitHubAuthContext,
12
+ type GitHubAuthProviderProps,
13
+ } from "./context.ts";
14
+
15
+ /**
16
+ * Generic GitHub Auth Provider component
17
+ * Manages user state and provides auth context to children
18
+ */
19
+ export function GitHubAuthProvider({
20
+ children,
21
+ storageKey,
22
+ onUserUpdate,
23
+ config,
24
+ }: GitHubAuthProviderProps) {
25
+ const [user, setUser] = useState<AuthUser | null>(null);
26
+ const [loading, setLoading] = useState(true);
27
+
28
+ useEffect(() => {
29
+ // Initial load from localStorage
30
+ const key = storageKey ?? "user";
31
+ const storedUser = localStorage.getItem(key);
32
+ if (storedUser) {
33
+ try {
34
+ const parsedUser = JSON.parse(storedUser) as AuthUser;
35
+ // Validate that parsed user is actually a valid user object
36
+ // Reject null, empty objects, or invalid values
37
+ if (
38
+ typeof parsedUser === "object" &&
39
+ Object.keys(parsedUser).length > 0
40
+ ) {
41
+ setUser(parsedUser);
42
+ } else {
43
+ // Invalid user data, clear it
44
+ localStorage.removeItem(key);
45
+ }
46
+ } catch (error) {
47
+ console.error("Error parsing stored user:", error);
48
+ localStorage.removeItem(key);
49
+ }
50
+ }
51
+ setLoading(false);
52
+ }, [storageKey]);
53
+
54
+ const updateUser = useCallback(
55
+ (newUser: AuthUser | null) => {
56
+ setUser(newUser);
57
+ const key = storageKey ?? "user";
58
+ if (newUser) {
59
+ localStorage.setItem(key, JSON.stringify(newUser));
60
+ } else {
61
+ localStorage.removeItem(key);
62
+ }
63
+ onUserUpdate?.(newUser);
64
+ },
65
+ [storageKey, onUserUpdate]
66
+ );
67
+
68
+ const signInWithGitHub = useCallback(() => {
69
+ if (config) {
70
+ const client = new GitHubAuthClient(config);
71
+ client.initiateLogin();
72
+ } else {
73
+ defaultInitiateLogin();
74
+ }
75
+ }, [config]);
76
+
77
+ const signOut = useCallback(() => {
78
+ setLoading(true);
79
+ const key = storageKey ?? "user";
80
+ localStorage.removeItem(key);
81
+ setUser(null);
82
+ onUserUpdate?.(null);
83
+ setLoading(false);
84
+ }, [storageKey, onUserUpdate]);
85
+
86
+ return (
87
+ <GitHubAuthContext.Provider
88
+ value={{ user, loading, signInWithGitHub, signOut, updateUser }}
89
+ >
90
+ {children}
91
+ </GitHubAuthContext.Provider>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Default GitHub Auth Provider with convention-over-configuration
97
+ * Uses sensible defaults: /api/auth/github/callback, storage key "user"
98
+ */
99
+ export function DefaultGitHubAuthProvider({
100
+ children,
101
+ onUserUpdate,
102
+ }: {
103
+ children: React.ReactNode;
104
+ onUserUpdate?: (user: AuthUser | null) => void;
105
+ }) {
106
+ return (
107
+ <GitHubAuthProvider storageKey="user" onUserUpdate={onUserUpdate}>
108
+ {children}
109
+ </GitHubAuthProvider>
110
+ );
111
+ }
@@ -0,0 +1,208 @@
1
+ # GitHub OAuth Authentication
2
+
3
+ This package provides frontend components and utilities for GitHub OAuth
4
+ authentication. It supports both **config-over-convention** (full control) and
5
+ **convention-over-configuration** (sensible defaults) approaches.
6
+
7
+ ## Features
8
+
9
+ - 🔐 Secure OAuth flow (client secret never exposed to frontend)
10
+ - ⚙️ Two API styles: configurable and default
11
+ - 🎨 React components for auth provider and callback handling
12
+ - 📦 TypeScript types for type safety
13
+ - 🔄 Automatic user state management with localStorage
14
+ - 🔧 Support for both frontend-initiated and backend-handled OAuth flows
15
+
16
+ ## Installation
17
+
18
+ This package is part of the `@chesapeake/shared-components` package. Import
19
+ from:
20
+
21
+ ```typescript
22
+ import { ... } from "@shared/components/ux/auth/github";
23
+ ```
24
+
25
+ ## Quick Start (Convention-over-Configuration)
26
+
27
+ For most use cases, the default client provides sensible defaults:
28
+
29
+ ### 1. Environment Variables
30
+
31
+ Add to your `.env` file:
32
+
33
+ ```env
34
+ VITE_GITHUB_CLIENT_ID=your_github_client_id_here
35
+ VITE_REDIRECT_URI=http://localhost:5173/auth/callback
36
+ ```
37
+
38
+ Or, if your backend handles OAuth initiation:
39
+
40
+ ```env
41
+ VITE_GITHUB_BACKEND_INIT_ENDPOINT=/api/auth/github
42
+ ```
43
+
44
+ ### 2. Setup Auth Provider
45
+
46
+ ```tsx
47
+ import {
48
+ DefaultGitHubAuthProvider,
49
+ useGitHubAuth,
50
+ } from "@shared/components/ux/auth/github";
51
+ import { defaultInitiateGitHubLogin } from "@shared/components/ux/auth/github";
52
+
53
+ function App() {
54
+ return (
55
+ <DefaultGitHubAuthProvider>
56
+ <YourApp />
57
+ </DefaultGitHubAuthProvider>
58
+ );
59
+ }
60
+
61
+ function LoginButton() {
62
+ const { signInWithGitHub } = useGitHubAuth();
63
+
64
+ return <button onClick={signInWithGitHub}>Sign in with GitHub</button>;
65
+ }
66
+ ```
67
+
68
+ ## Backend Implementation
69
+
70
+ ### API Contract
71
+
72
+ **Endpoint:** `POST /api/auth/github/callback`
73
+
74
+ **Request:**
75
+
76
+ ```json
77
+ {
78
+ "code": "authorization_code_from_github",
79
+ "state": "optional_state_parameter"
80
+ }
81
+ ```
82
+
83
+ **Response:**
84
+
85
+ ```json
86
+ {
87
+ "user": {
88
+ "id": 12345678,
89
+ "login": "username",
90
+ "name": "User Name",
91
+ "email": "user@example.com",
92
+ "avatar_url": "https://avatars.githubusercontent.com/u/12345678"
93
+ }
94
+ }
95
+ ```
96
+
97
+ ### Backend-Handled OAuth Flow
98
+
99
+ If your backend handles the entire OAuth flow (initiation and callback),
100
+ configure the client with `backendInitEndpoint`:
101
+
102
+ ```tsx
103
+ import { GitHubAuthProvider } from "@shared/components/ux/auth/github";
104
+
105
+ function App() {
106
+ return (
107
+ <GitHubAuthProvider
108
+ config={{
109
+ clientId: "", // Not needed when using backend endpoint
110
+ redirectUri: window.location.origin + "/api/auth/github/callback",
111
+ backendInitEndpoint: "/api/auth/github", // Backend handles OAuth initiation
112
+ }}
113
+ >
114
+ <YourApp />
115
+ </GitHubAuthProvider>
116
+ );
117
+ }
118
+ ```
119
+
120
+ ### Minimal Deno Implementation
121
+
122
+ Here's a complete example for Deno:
123
+
124
+ ```typescript
125
+ // Backend OAuth initiation endpoint
126
+ async function handleGitHubAuth(req: Request): Promise<Response> {
127
+ const GITHUB_CLIENT_ID = Deno.env.get("GITHUB_CLIENT_ID");
128
+ const GITHUB_REDIRECT_URI = Deno.env.get("GITHUB_REDIRECT_URI");
129
+
130
+ const state = crypto.randomUUID();
131
+ const authUrl = new URL("https://github.com/login/oauth/authorize");
132
+ authUrl.searchParams.set("client_id", GITHUB_CLIENT_ID!);
133
+ authUrl.searchParams.set("redirect_uri", GITHUB_REDIRECT_URI);
134
+ authUrl.searchParams.set("scope", "user:email");
135
+ authUrl.searchParams.set("state", state);
136
+
137
+ // Store state for validation
138
+ await kv.set(["oauth_state", state], { expiresAt: Date.now() + 600000 });
139
+
140
+ return Response.redirect(authUrl.toString());
141
+ }
142
+
143
+ // Backend OAuth callback endpoint
144
+ async function handleGitHubCallback(req: Request): Promise<Response> {
145
+ const url = new URL(req.url);
146
+ const code = url.searchParams.get("code");
147
+ const state = url.searchParams.get("state");
148
+
149
+ // Validate state
150
+ const stateResult = await kv.get(["oauth_state", state!]);
151
+ if (!stateResult.value) {
152
+ return new Response("Invalid state", { status: 400 });
153
+ }
154
+ await kv.delete(["oauth_state", state!]);
155
+
156
+ // Exchange code for token
157
+ const tokenResponse = await fetch(
158
+ "https://github.com/login/oauth/access_token",
159
+ {
160
+ method: "POST",
161
+ headers: {
162
+ Accept: "application/json",
163
+ "Content-Type": "application/x-www-form-urlencoded",
164
+ },
165
+ body: new URLSearchParams({
166
+ client_id: Deno.env.get("GITHUB_CLIENT_ID")!,
167
+ client_secret: Deno.env.get("GITHUB_CLIENT_SECRET")!,
168
+ code: code!,
169
+ redirect_uri: Deno.env.get("GITHUB_REDIRECT_URI")!,
170
+ }),
171
+ },
172
+ );
173
+
174
+ const tokenData = await tokenResponse.json();
175
+ const accessToken = tokenData.access_token;
176
+
177
+ // Get user data
178
+ const userResponse = await fetch("https://api.github.com/user", {
179
+ headers: {
180
+ Authorization: `Bearer ${accessToken}`,
181
+ Accept: "application/vnd.github.v3+json",
182
+ },
183
+ });
184
+
185
+ const user = await userResponse.json();
186
+
187
+ // Return user profile
188
+ return new Response(
189
+ JSON.stringify({
190
+ user: {
191
+ id: user.id,
192
+ login: user.login,
193
+ name: user.name,
194
+ email: user.email,
195
+ avatar_url: user.avatar_url,
196
+ },
197
+ }),
198
+ {
199
+ headers: { "Content-Type": "application/json" },
200
+ },
201
+ );
202
+ }
203
+ ```
204
+
205
+ ## Advanced Usage
206
+
207
+ See the main [auth README](../README.md) for multi-provider setup and advanced
208
+ configuration options.
@@ -0,0 +1,127 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { GitHubAuthCallbackResponse, GitHubAuthConfig } from "./config.ts";
5
+ import type { GitHubOAuthProfile } from "./types.ts";
6
+
7
+ /**
8
+ * Configurable GitHub OAuth client (config-over-convention)
9
+ * Provides full control over all OAuth parameters
10
+ */
11
+ export class GitHubAuthClient {
12
+ private config: GitHubAuthConfig;
13
+
14
+ constructor(config: GitHubAuthConfig) {
15
+ // Validate config
16
+ if (!config.backendInitEndpoint && !config.clientId) {
17
+ throw new Error(
18
+ "Either clientId or backendInitEndpoint must be provided"
19
+ );
20
+ }
21
+ this.config = config;
22
+ }
23
+
24
+ /**
25
+ * Initiates GitHub OAuth login flow by redirecting to GitHub or backend endpoint
26
+ */
27
+ initiateLogin(): void {
28
+ // If backend handles OAuth initiation, redirect to backend endpoint
29
+ if (this.config.backendInitEndpoint) {
30
+ globalThis.location.href = this.config.backendInitEndpoint;
31
+ return;
32
+ }
33
+
34
+ // Otherwise, redirect directly to GitHub
35
+ const params = new URLSearchParams({
36
+ client_id: this.config.clientId,
37
+ redirect_uri: this.config.redirectUri,
38
+ scope: this.config.scope ?? "user:email",
39
+ state: this.generateState(),
40
+ });
41
+
42
+ // Store state in sessionStorage for validation
43
+ const state = params.get("state");
44
+ if (state) {
45
+ sessionStorage.setItem(`github_oauth_state_${state}`, "pending");
46
+ }
47
+
48
+ globalThis.location.href = `https://github.com/login/oauth/authorize?${params.toString()}`;
49
+ }
50
+
51
+ /**
52
+ * Generates a secure random state parameter for OAuth
53
+ */
54
+ private generateState(): string {
55
+ return crypto.randomUUID();
56
+ }
57
+
58
+ /**
59
+ * Handles OAuth callback by exchanging code for user profile via backend
60
+ * @param code Authorization code from GitHub OAuth callback
61
+ * @param state State parameter from GitHub OAuth callback (optional, for validation)
62
+ * @returns User profile from backend
63
+ */
64
+ async handleCallback(
65
+ code: string,
66
+ state?: string
67
+ ): Promise<GitHubOAuthProfile> {
68
+ // Validate state if provided
69
+ if (state) {
70
+ const storedState = sessionStorage.getItem(`github_oauth_state_${state}`);
71
+ if (!storedState) {
72
+ throw new Error("Invalid state parameter");
73
+ }
74
+ sessionStorage.removeItem(`github_oauth_state_${state}`);
75
+ }
76
+
77
+ const apiBaseUrl = this.config.apiBaseUrl ?? "";
78
+ const endpoint = `${apiBaseUrl}/api/auth/github/callback`;
79
+
80
+ const response = await fetch(endpoint, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/json",
84
+ },
85
+ body: JSON.stringify({ code, state }),
86
+ });
87
+
88
+ if (!response.ok) {
89
+ const error = (await response.json().catch(() => ({
90
+ error: "Failed to exchange code for token",
91
+ }))) as {
92
+ error_description?: string;
93
+ error?: string;
94
+ };
95
+ throw new Error(
96
+ error.error_description ??
97
+ error.error ??
98
+ "Failed to exchange code for token"
99
+ );
100
+ }
101
+
102
+ const data = (await response.json()) as GitHubAuthCallbackResponse;
103
+ return data.user;
104
+ }
105
+
106
+ /**
107
+ * Updates the configuration
108
+ */
109
+ updateConfig(config: Partial<GitHubAuthConfig>): void {
110
+ this.config = { ...this.config, ...config };
111
+ }
112
+
113
+ /**
114
+ * Gets the current configuration
115
+ */
116
+ getConfig(): GitHubAuthConfig {
117
+ return { ...this.config };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Standalone function to initiate GitHub OAuth login with configuration
123
+ */
124
+ export function initiateGitHubLogin(config: GitHubAuthConfig): void {
125
+ const client = new GitHubAuthClient(config);
126
+ client.initiateLogin();
127
+ }
@@ -0,0 +1,68 @@
1
+ // Copyright 2026 Chesapeake Computing
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type { GitHubOAuthProfile } from "./types.ts";
5
+
6
+ /**
7
+ * Configuration for GitHub OAuth client
8
+ */
9
+ export interface GitHubAuthConfig {
10
+ /** GitHub OAuth client ID */
11
+ clientId: string;
12
+ /** Redirect URI after OAuth authorization */
13
+ redirectUri: string;
14
+ /** Backend API base URL (e.g., "https://api.example.com" or "/api") */
15
+ apiBaseUrl?: string;
16
+ /** OAuth scopes (default: "user:email") */
17
+ scope?: string;
18
+ /** Storage key for user data in localStorage (default: "user") */
19
+ storageKey?: string;
20
+ /** Backend endpoint for OAuth initiation (if backend handles OAuth flow) */
21
+ backendInitEndpoint?: string;
22
+ }
23
+
24
+ /**
25
+ * Default OAuth scopes
26
+ */
27
+ export const DEFAULT_SCOPE = "user:email";
28
+
29
+ /**
30
+ * Creates a GitHubAuthConfig from environment variables
31
+ * Note: This function expects Vite environment variables (import.meta.env)
32
+ */
33
+ export function createGitHubAuthConfig(): GitHubAuthConfig {
34
+ // Type assertion for Vite's import.meta.env
35
+ const env = (import.meta as { env?: Record<string, string | undefined> })
36
+ .env as {
37
+ VITE_GITHUB_CLIENT_ID?: string;
38
+ VITE_REDIRECT_URI?: string;
39
+ VITE_API_BASE_URL?: string;
40
+ VITE_GITHUB_BACKEND_INIT_ENDPOINT?: string;
41
+ };
42
+
43
+ const clientId = env.VITE_GITHUB_CLIENT_ID;
44
+ if (!clientId && !env.VITE_GITHUB_BACKEND_INIT_ENDPOINT) {
45
+ throw new Error(
46
+ "VITE_GITHUB_CLIENT_ID or VITE_GITHUB_BACKEND_INIT_ENDPOINT environment variable is required"
47
+ );
48
+ }
49
+
50
+ const redirectUri =
51
+ env.VITE_REDIRECT_URI ?? `${globalThis.location.origin}/auth/callback`;
52
+
53
+ return {
54
+ clientId: clientId ?? "",
55
+ redirectUri,
56
+ apiBaseUrl: env.VITE_API_BASE_URL,
57
+ scope: DEFAULT_SCOPE,
58
+ storageKey: "user",
59
+ backendInitEndpoint: env.VITE_GITHUB_BACKEND_INIT_ENDPOINT,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Backend API response from OAuth callback endpoint
65
+ */
66
+ export interface GitHubAuthCallbackResponse {
67
+ user: GitHubOAuthProfile;
68
+ }