drbh commited on
Commit
dc06026
Β·
1 Parent(s): 6a11e08

feat: refactor for linking

Browse files
app/lib/account-linking.server.ts ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { dirname } from 'path';
3
+
4
+ export interface AccountLink {
5
+ githubUserId: string;
6
+ githubLogin: string;
7
+ huggingfaceUsername: string;
8
+ linkedAt: string;
9
+ lastUpdated: string;
10
+ }
11
+
12
+ export interface AccountLinksData {
13
+ links: AccountLink[];
14
+ metadata: {
15
+ version: string;
16
+ createdAt: string;
17
+ lastModified: string;
18
+ };
19
+ }
20
+
21
+ export class AccountLinkingService {
22
+ private filePath: string;
23
+
24
+ constructor() {
25
+ this.filePath = process.env.ACCOUNT_LINKS_FILE || './data/account-links.json';
26
+ this.ensureFileExists();
27
+ }
28
+
29
+ private ensureFileExists(): void {
30
+ const dir = dirname(this.filePath);
31
+
32
+ // Create directory if it doesn't exist
33
+ if (!existsSync(dir)) {
34
+ mkdirSync(dir, { recursive: true });
35
+ }
36
+
37
+ // Create file if it doesn't exist
38
+ if (!existsSync(this.filePath)) {
39
+ const initialData: AccountLinksData = {
40
+ links: [],
41
+ metadata: {
42
+ version: '1.0.0',
43
+ createdAt: new Date().toISOString(),
44
+ lastModified: new Date().toISOString(),
45
+ },
46
+ };
47
+ this.writeData(initialData);
48
+ }
49
+ }
50
+
51
+ private readData(): AccountLinksData {
52
+ try {
53
+ const content = readFileSync(this.filePath, 'utf-8');
54
+ return JSON.parse(content);
55
+ } catch (error) {
56
+ console.error('Error reading account links file:', error);
57
+ // Return default structure if file is corrupted
58
+ return {
59
+ links: [],
60
+ metadata: {
61
+ version: '1.0.0',
62
+ createdAt: new Date().toISOString(),
63
+ lastModified: new Date().toISOString(),
64
+ },
65
+ };
66
+ }
67
+ }
68
+
69
+ private writeData(data: AccountLinksData): void {
70
+ try {
71
+ data.metadata.lastModified = new Date().toISOString();
72
+ writeFileSync(this.filePath, JSON.stringify(data, null, 2), 'utf-8');
73
+ } catch (error) {
74
+ console.error('Error writing account links file:', error);
75
+ throw new Error('Failed to save account links');
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Create a new account link
81
+ */
82
+ createLink(githubUserId: string, githubLogin: string, huggingfaceUsername: string): AccountLink {
83
+ const data = this.readData();
84
+ const now = new Date().toISOString();
85
+
86
+ // Check for existing links
87
+ const existingGithubLink = data.links.find(link => link.githubUserId === githubUserId);
88
+ const existingHfLink = data.links.find(link => link.huggingfaceUsername === huggingfaceUsername);
89
+
90
+ if (existingGithubLink) {
91
+ throw new Error(`GitHub user ${githubLogin} is already linked to HuggingFace user ${existingGithubLink.huggingfaceUsername}`);
92
+ }
93
+
94
+ if (existingHfLink) {
95
+ throw new Error(`HuggingFace user ${huggingfaceUsername} is already linked to GitHub user ${existingHfLink.githubLogin}`);
96
+ }
97
+
98
+ const newLink: AccountLink = {
99
+ githubUserId,
100
+ githubLogin,
101
+ huggingfaceUsername,
102
+ linkedAt: now,
103
+ lastUpdated: now,
104
+ };
105
+
106
+ data.links.push(newLink);
107
+ this.writeData(data);
108
+
109
+ console.log(`βœ… Created account link: ${githubLogin} ↔ ${huggingfaceUsername}`);
110
+ return newLink;
111
+ }
112
+
113
+ /**
114
+ * Find account link by GitHub user
115
+ */
116
+ findByGitHubUser(githubUserId: string): AccountLink | null {
117
+ const data = this.readData();
118
+ return data.links.find(link => link.githubUserId === githubUserId) || null;
119
+ }
120
+
121
+ /**
122
+ * Find account link by HuggingFace user
123
+ */
124
+ findByHuggingFaceUser(huggingfaceUsername: string): AccountLink | null {
125
+ const data = this.readData();
126
+ return data.links.find(link => link.huggingfaceUsername === huggingfaceUsername) || null;
127
+ }
128
+
129
+ /**
130
+ * Update an existing account link
131
+ */
132
+ updateLink(githubUserId: string, updates: Partial<Omit<AccountLink, 'githubUserId' | 'linkedAt'>>): AccountLink {
133
+ const data = this.readData();
134
+ const linkIndex = data.links.findIndex(link => link.githubUserId === githubUserId);
135
+
136
+ if (linkIndex === -1) {
137
+ throw new Error(`No account link found for GitHub user ID: ${githubUserId}`);
138
+ }
139
+
140
+ const existingLink = data.links[linkIndex];
141
+ const updatedLink: AccountLink = {
142
+ ...existingLink,
143
+ ...updates,
144
+ lastUpdated: new Date().toISOString(),
145
+ };
146
+
147
+ data.links[linkIndex] = updatedLink;
148
+ this.writeData(data);
149
+
150
+ console.log(`βœ… Updated account link for GitHub user: ${existingLink.githubLogin}`);
151
+ return updatedLink;
152
+ }
153
+
154
+ /**
155
+ * Remove an account link
156
+ */
157
+ removeLink(githubUserId: string): boolean {
158
+ const data = this.readData();
159
+ const initialLength = data.links.length;
160
+
161
+ data.links = data.links.filter(link => link.githubUserId !== githubUserId);
162
+
163
+ if (data.links.length < initialLength) {
164
+ this.writeData(data);
165
+ console.log(`βœ… Removed account link for GitHub user ID: ${githubUserId}`);
166
+ return true;
167
+ }
168
+
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * Get all account links
174
+ */
175
+ getAllLinks(): AccountLink[] {
176
+ const data = this.readData();
177
+ return data.links;
178
+ }
179
+
180
+ /**
181
+ * Get linking statistics
182
+ */
183
+ getStats(): { totalLinks: number; lastModified: string } {
184
+ const data = this.readData();
185
+ return {
186
+ totalLinks: data.links.length,
187
+ lastModified: data.metadata.lastModified,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Check if accounts can be linked (no conflicts)
193
+ */
194
+ canLink(githubUserId: string, huggingfaceUsername: string): { canLink: boolean; reason?: string } {
195
+ const data = this.readData();
196
+
197
+ const existingGithubLink = data.links.find(link => link.githubUserId === githubUserId);
198
+ if (existingGithubLink) {
199
+ return {
200
+ canLink: false,
201
+ reason: `GitHub account is already linked to HuggingFace user: ${existingGithubLink.huggingfaceUsername}`,
202
+ };
203
+ }
204
+
205
+ const existingHfLink = data.links.find(link => link.huggingfaceUsername === huggingfaceUsername);
206
+ if (existingHfLink) {
207
+ return {
208
+ canLink: false,
209
+ reason: `HuggingFace account is already linked to GitHub user: ${existingHfLink.githubLogin}`,
210
+ };
211
+ }
212
+
213
+ return { canLink: true };
214
+ }
215
+ }
216
+
217
+ // Singleton instance
218
+ export const accountLinkingService = new AccountLinkingService();
app/lib/huggingface-oauth.server.ts ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HuggingFaceUserInfo } from './session.server';
2
+
3
+ const HF_OAUTH_BASE_URL = 'https://huggingface.co/oauth';
4
+ const HF_API_BASE_URL = 'https://huggingface.co/api';
5
+
6
+ export class HuggingFaceOAuthService {
7
+ private clientId: string;
8
+ private clientSecret: string;
9
+ private redirectUri: string;
10
+
11
+ constructor() {
12
+ this.clientId = process.env.HF_CLIENT_ID || '';
13
+ this.clientSecret = process.env.HF_CLIENT_SECRET || '';
14
+ // Support both variable names for backward compatibility
15
+ this.redirectUri = process.env.HF_REDIRECT_URI || process.env.HF_CALLBACK_URL || '';
16
+
17
+ if (!this.clientId || !this.clientSecret || !this.redirectUri) {
18
+ console.warn('⚠️ HuggingFace OAuth not fully configured. Missing environment variables:');
19
+ console.warn('- HF_CLIENT_ID:', this.clientId ? 'βœ… Set' : '❌ Missing');
20
+ console.warn('- HF_CLIENT_SECRET:', this.clientSecret ? 'βœ… Set' : '❌ Missing');
21
+ console.warn('- HF_REDIRECT_URI/HF_CALLBACK_URL:', this.redirectUri ? 'βœ… Set' : '❌ Missing');
22
+ } else {
23
+ console.log('βœ… HuggingFace OAuth configuration:');
24
+ console.log('- Client ID:', this.clientId.substring(0, 4) + '...');
25
+ console.log('- Redirect URI:', this.redirectUri);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Check if HuggingFace OAuth is properly configured
31
+ */
32
+ isConfigured(): boolean {
33
+ return !!(this.clientId && this.clientSecret && this.redirectUri);
34
+ }
35
+
36
+ /**
37
+ * Generate authorization URL for HuggingFace OAuth
38
+ */
39
+ getAuthorizationUrl(state: string, codeChallenge: string): string {
40
+ if (!this.isConfigured()) {
41
+ throw new Error('HuggingFace OAuth is not properly configured');
42
+ }
43
+
44
+ // Directly use the URL constructor to avoid any serialization issues with URLSearchParams
45
+ let authUrl = new URL(`${HF_OAUTH_BASE_URL}/authorize`);
46
+ authUrl.searchParams.set('client_id', this.clientId);
47
+ authUrl.searchParams.set('response_type', 'code');
48
+ authUrl.searchParams.set('scope', 'profile'); // Explicitly use 'profile' as required by HF
49
+ authUrl.searchParams.set('redirect_uri', this.redirectUri);
50
+ authUrl.searchParams.set('state', state);
51
+ authUrl.searchParams.set('code_challenge', codeChallenge);
52
+ authUrl.searchParams.set('code_challenge_method', 'S256');
53
+
54
+ console.log('Debug - Auth URL:', authUrl.toString());
55
+
56
+ return authUrl.toString();
57
+ }
58
+
59
+ /**
60
+ * Exchange authorization code for access token
61
+ */
62
+ async exchangeCodeForToken(code: string, codeVerifier: string): Promise<string> {
63
+ if (!this.isConfigured()) {
64
+ throw new Error('HuggingFace OAuth is not properly configured');
65
+ }
66
+
67
+ console.log('Debug - Token exchange parameters:');
68
+ console.log('- Code:', code.substring(0, 4) + '...');
69
+ console.log('- Code verifier length:', codeVerifier.length);
70
+ console.log('- Redirect URI:', this.redirectUri);
71
+
72
+ const formData = new URLSearchParams();
73
+ formData.append('grant_type', 'authorization_code');
74
+ formData.append('client_id', this.clientId);
75
+ formData.append('client_secret', this.clientSecret);
76
+ formData.append('code', code);
77
+ formData.append('redirect_uri', this.redirectUri);
78
+ formData.append('code_verifier', codeVerifier);
79
+
80
+ console.log('Debug - Request body:', formData.toString());
81
+
82
+ const response = await fetch(`${HF_OAUTH_BASE_URL}/token`, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/x-www-form-urlencoded',
86
+ 'Accept': 'application/json',
87
+ },
88
+ body: formData,
89
+ });
90
+
91
+ if (!response.ok) {
92
+ const errorText = await response.text();
93
+ console.error('HuggingFace token exchange failed:', errorText);
94
+ console.error('Response status:', response.status);
95
+ console.error('Response headers:', Object.fromEntries(response.headers.entries()));
96
+ throw new Error(`Failed to exchange code for token: ${response.status} - ${errorText}`);
97
+ }
98
+
99
+ const tokenData = await response.json();
100
+
101
+ if (!tokenData.access_token) {
102
+ throw new Error('No access token received from HuggingFace');
103
+ }
104
+
105
+ return tokenData.access_token;
106
+ }
107
+
108
+ /**
109
+ * Get user information from HuggingFace API
110
+ */
111
+ async getUserInfo(accessToken: string): Promise<HuggingFaceUserInfo> {
112
+ // First try the v2 endpoint
113
+ try {
114
+ const response = await fetch(`${HF_API_BASE_URL}/whoami-v2`, {
115
+ headers: {
116
+ 'Authorization': `Bearer ${accessToken}`,
117
+ 'Accept': 'application/json',
118
+ },
119
+ });
120
+
121
+ if (response.ok) {
122
+ const userData = await response.json();
123
+ console.log('Debug - User data keys:', Object.keys(userData));
124
+
125
+ return {
126
+ username: userData.name || '',
127
+ fullName: userData.fullname || userData.name || '',
128
+ email: userData.email || undefined,
129
+ avatarUrl: userData.avatarUrl || undefined,
130
+ };
131
+ }
132
+ } catch (error) {
133
+ console.log('Error with whoami-v2 endpoint, falling back to v1');
134
+ }
135
+
136
+ // Fall back to v1 endpoint
137
+ const response = await fetch(`${HF_API_BASE_URL}/whoami`, {
138
+ headers: {
139
+ 'Authorization': `Bearer ${accessToken}`,
140
+ 'Accept': 'application/json',
141
+ },
142
+ });
143
+
144
+ if (!response.ok) {
145
+ const errorText = await response.text();
146
+ console.error('Failed to get HuggingFace user info:', errorText);
147
+ throw new Error(`Failed to get user info: ${response.status} - ${errorText}`);
148
+ }
149
+
150
+ const userData = await response.json();
151
+ console.log('Debug - User data keys (v1):', Object.keys(userData));
152
+
153
+ return {
154
+ username: userData.name || userData.user || '',
155
+ fullName: userData.fullname || userData.name || userData.user || '',
156
+ email: userData.email || undefined,
157
+ avatarUrl: userData.avatarUrl || userData.avatar_url || undefined,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Complete OAuth flow: exchange code and get user info
163
+ */
164
+ async completeOAuthFlow(code: string, codeVerifier: string): Promise<{
165
+ accessToken: string;
166
+ userInfo: HuggingFaceUserInfo;
167
+ }> {
168
+ const accessToken = await this.exchangeCodeForToken(code, codeVerifier);
169
+ const userInfo = await this.getUserInfo(accessToken);
170
+
171
+ return { accessToken, userInfo };
172
+ }
173
+ }
174
+
175
+ // Singleton instance
176
+ export const huggingFaceOAuth = new HuggingFaceOAuthService();
app/lib/oauth-utils.server.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash, randomBytes } from 'crypto';
2
+
3
+ /**
4
+ * Generate a cryptographically secure random string
5
+ */
6
+ export function generateRandomString(length: number): string {
7
+ const bytes = randomBytes(Math.ceil(length / 2));
8
+ return bytes.toString('hex').slice(0, length);
9
+ }
10
+
11
+ /**
12
+ * Create a code challenge for PKCE (Proof Key for Code Exchange)
13
+ */
14
+ export async function createCodeChallenge(codeVerifier: string): Promise<string> {
15
+ const hash = createHash('sha256');
16
+ hash.update(codeVerifier);
17
+ const digest = hash.digest();
18
+
19
+ // Convert to base64url (URL-safe base64 without padding)
20
+ return digest
21
+ .toString('base64')
22
+ .replace(/\+/g, '-')
23
+ .replace(/\//g, '_')
24
+ .replace(/=/g, '');
25
+ }
26
+
27
+ /**
28
+ * Parse cookies from cookie header string
29
+ */
30
+ export function parseCookies(cookieHeader: string): Record<string, string> {
31
+ const cookies: Record<string, string> = {};
32
+
33
+ if (!cookieHeader) return cookies;
34
+
35
+ cookieHeader.split(';').forEach(cookie => {
36
+ const [name, ...rest] = cookie.trim().split('=');
37
+ if (name && rest.length > 0) {
38
+ cookies[name] = rest.join('=');
39
+ }
40
+ });
41
+
42
+ return cookies;
43
+ }
app/lib/session.server.ts CHANGED
@@ -2,7 +2,7 @@ import { createCookieSessionStorage } from "@remix-run/node";
2
 
3
  export { getSession, commitSession, destroySession };
4
 
5
- export interface UserSession {
6
  userId: string;
7
  login: string;
8
  name?: string;
@@ -10,6 +10,20 @@ export interface UserSession {
10
  avatar_url?: string;
11
  }
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  const sessionSecret = process.env.SESSION_SECRET;
14
  if (!sessionSecret) {
15
  throw new Error('SESSION_SECRET environment variable is required');
 
2
 
3
  export { getSession, commitSession, destroySession };
4
 
5
+ export interface GitHubUserInfo {
6
  userId: string;
7
  login: string;
8
  name?: string;
 
10
  avatar_url?: string;
11
  }
12
 
13
+ export interface HuggingFaceUserInfo {
14
+ username: string;
15
+ fullName?: string;
16
+ email?: string;
17
+ avatarUrl?: string;
18
+ }
19
+
20
+ export interface UserSession {
21
+ github?: GitHubUserInfo;
22
+ huggingface?: HuggingFaceUserInfo;
23
+ isLinked: boolean;
24
+ linkedAt?: string;
25
+ }
26
+
27
  const sessionSecret = process.env.SESSION_SECRET;
28
  if (!sessionSecret) {
29
  throw new Error('SESSION_SECRET environment variable is required');
app/routes/_index.tsx CHANGED
@@ -2,47 +2,76 @@ import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node";
2
  import { json } from "@remix-run/node";
3
  import { useLoaderData, Link, useSearchParams } from "@remix-run/react";
4
  import { getUserSession } from "~/lib/session.server";
 
5
 
6
  export const meta: MetaFunction = () => {
7
  return [
8
- { title: "HugeX GitHub App" },
9
- { name: "description", content: "GitHub App with user authentication" },
10
  ];
11
  };
12
 
13
  export async function loader({ request }: LoaderFunctionArgs) {
14
- const user = await getUserSession(request);
15
  const url = new URL(request.url);
16
  const error = url.searchParams.get("error");
17
  const details = url.searchParams.get("details");
18
  const message = url.searchParams.get("message");
19
 
20
- return json({ user, error, details, message });
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
22
 
23
  export default function Index() {
24
- const { user, error, details, message } = useLoaderData<typeof loader>();
25
  const [searchParams] = useSearchParams();
26
 
 
 
 
 
 
27
  return (
28
  <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
29
  <div className="flex h-screen items-center justify-center">
30
  <div className="flex flex-col items-center gap-8 max-w-2xl mx-auto px-4">
31
  <header className="flex flex-col items-center gap-6">
32
- <div className="bg-white rounded-full p-4 shadow-lg">
33
- <svg
34
- className="w-16 h-16 text-blue-600"
35
- fill="currentColor"
36
- viewBox="0 0 24 24"
37
- >
38
- <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
39
- </svg>
 
 
 
 
 
 
 
 
 
 
 
40
  </div>
41
  <h1 className="text-4xl font-bold text-gray-900">
42
- HugeX GitHub App
43
  </h1>
44
  <p className="text-lg text-gray-600 text-center">
45
- GitHub App with user authentication and webhook support
46
  </p>
47
  </header>
48
 
@@ -66,23 +95,23 @@ export default function Index() {
66
  {details && <p className="mt-1 font-mono text-xs">Details: {details}</p>}
67
  </div>
68
  )}
69
- {error === "no_code" && "No authorization code received from GitHub."}
70
  {error === "callback_failed" && (
71
  <div>
72
  <p>Failed to complete authentication.</p>
73
  {message && <p className="mt-1 font-mono text-xs">Error: {message}</p>}
74
  </div>
75
  )}
76
- {error === "expired_code" && (
77
  <div>
78
- <p>The authorization code has expired or is invalid.</p>
79
- <p className="mt-1">Please try signing in again.</p>
80
  </div>
81
  )}
82
- {error === "invalid_client" && (
83
  <div>
84
- <p>GitHub App configuration error.</p>
85
- <p className="mt-1">Please check the client ID and secret.</p>
86
  </div>
87
  )}
88
  </div>
@@ -91,106 +120,218 @@ export default function Index() {
91
  </div>
92
  )}
93
 
94
- {/* Main Content */}
95
- {user ? (
96
- <div className="bg-white rounded-lg shadow-lg p-8 w-full">
97
- <div className="text-center">
98
- <div className="flex items-center justify-center mb-4">
99
- {user.avatar_url && (
100
- <img
101
- src={user.avatar_url}
102
- alt={user.login}
103
- className="w-16 h-16 rounded-full mr-4"
104
- />
105
- )}
106
- <div>
107
- <h2 className="text-2xl font-bold text-gray-900">
108
- Welcome back, {user.name || user.login}!
109
- </h2>
110
- <p className="text-gray-600">@{user.login}</p>
111
- </div>
112
- </div>
113
- <div className="mt-6 flex gap-4 justify-center">
114
- <Link
115
- to="/dashboard"
116
- className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
117
  >
118
- Go to Dashboard
119
- </Link>
120
- <Link
121
- to="/status"
122
- className="inline-flex items-center px-6 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  >
124
- Check Status
125
- </Link>
126
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  </div>
128
  </div>
129
- ) : (
130
- <div className="bg-white rounded-lg shadow-lg p-8 w-full">
131
- <div className="text-center">
132
- <h2 className="text-2xl font-bold text-gray-900 mb-4">
133
- Get Started
134
- </h2>
135
- <p className="text-gray-600 mb-6">
136
- Authenticate with GitHub to enable user-specific actions when webhooks are triggered.
137
- </p>
138
- <Link
139
- to="/auth/github"
140
- className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
141
- >
142
- <svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
143
- <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
144
- </svg>
145
- Sign in with GitHub
146
- </Link>
147
- <div className="mt-4">
148
- <Link
149
- to="/status"
150
- className="text-sm text-blue-600 hover:text-blue-700 font-medium"
151
- >
152
- Check Environment Status
153
- </Link>
154
  </div>
155
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  </div>
157
- )}
158
 
159
- {/* Features */}
160
- <div className="grid md:grid-cols-3 gap-6 w-full mt-8">
161
- <div className="bg-white rounded-lg shadow p-6">
162
- <div className="text-blue-600 mb-3">
163
- <svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
165
- </svg>
166
  </div>
167
- <h3 className="text-lg font-semibold text-gray-900 mb-2">User Authentication</h3>
 
 
 
 
 
 
168
  <p className="text-gray-600 text-sm">
169
- Authenticate users with GitHub OAuth to perform actions on their behalf.
170
  </p>
171
  </div>
172
 
173
  <div className="bg-white rounded-lg shadow p-6">
174
- <div className="text-green-600 mb-3">
175
- <svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
176
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
177
- </svg>
178
- </div>
179
- <h3 className="text-lg font-semibold text-gray-900 mb-2">Webhook Support</h3>
180
  <p className="text-gray-600 text-sm">
181
- Handle GitHub webhooks and associate them with authenticated users.
182
  </p>
183
  </div>
184
 
185
  <div className="bg-white rounded-lg shadow p-6">
186
- <div className="text-purple-600 mb-3">
187
- <svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
188
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
189
- </svg>
190
- </div>
191
- <h3 className="text-lg font-semibold text-gray-900 mb-2">API Integration</h3>
192
  <p className="text-gray-600 text-sm">
193
- Use authenticated user sessions to make GitHub API calls.
194
  </p>
195
  </div>
196
  </div>
@@ -198,6 +339,4 @@ export default function Index() {
198
  </div>
199
  </div>
200
  );
201
- }
202
-
203
-
 
2
  import { json } from "@remix-run/node";
3
  import { useLoaderData, Link, useSearchParams } from "@remix-run/react";
4
  import { getUserSession } from "~/lib/session.server";
5
+ import { accountLinkingService } from "~/lib/account-linking.server";
6
 
7
  export const meta: MetaFunction = () => {
8
  return [
9
+ { title: "GitHub + HuggingFace Account Linking" },
10
+ { name: "description", content: "Link your GitHub and HuggingFace accounts" },
11
  ];
12
  };
13
 
14
  export async function loader({ request }: LoaderFunctionArgs) {
15
+ const userSession = await getUserSession(request);
16
  const url = new URL(request.url);
17
  const error = url.searchParams.get("error");
18
  const details = url.searchParams.get("details");
19
  const message = url.searchParams.get("message");
20
 
21
+ // Get linking stats if available
22
+ let linkingStats = null;
23
+ if (userSession) {
24
+ linkingStats = accountLinkingService.getStats();
25
+ }
26
+
27
+ return json({
28
+ userSession,
29
+ error,
30
+ details,
31
+ message,
32
+ linkingStats
33
+ });
34
  }
35
 
36
  export default function Index() {
37
+ const { userSession, error, details, message, linkingStats } = useLoaderData<typeof loader>();
38
  const [searchParams] = useSearchParams();
39
 
40
+ // Determine authentication status
41
+ const hasGitHub = !!userSession?.github;
42
+ const hasHuggingFace = !!userSession?.huggingface;
43
+ const isLinked = userSession?.isLinked || false;
44
+
45
  return (
46
  <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
47
  <div className="flex h-screen items-center justify-center">
48
  <div className="flex flex-col items-center gap-8 max-w-2xl mx-auto px-4">
49
  <header className="flex flex-col items-center gap-6">
50
+ <div className="flex space-x-4">
51
+ <div className="bg-white rounded-full p-4 shadow-lg">
52
+ <svg
53
+ className="w-16 h-16 text-blue-600"
54
+ fill="currentColor"
55
+ viewBox="0 0 24 24"
56
+ >
57
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
58
+ </svg>
59
+ </div>
60
+ <div className="bg-white rounded-full p-4 shadow-lg">
61
+ <svg
62
+ className="w-16 h-16 text-yellow-500"
63
+ viewBox="0 0 24 24"
64
+ fill="currentColor"
65
+ >
66
+ <path d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 2.25c2.387 0 4.5 1.773 4.5 4.5S14.387 13.5 12 13.5 7.5 11.727 7.5 9s2.113-4.5 4.5-4.5zm0 16.5a9.26 9.26 0 01-6.75-2.798c.042-2.233 4.5-3.452 6.75-3.452s6.708 1.219 6.75 3.452A9.26 9.26 0 0112 21z" />
67
+ </svg>
68
+ </div>
69
  </div>
70
  <h1 className="text-4xl font-bold text-gray-900">
71
+ Account Linking
72
  </h1>
73
  <p className="text-lg text-gray-600 text-center">
74
+ Link your GitHub and HuggingFace accounts in one place
75
  </p>
76
  </header>
77
 
 
95
  {details && <p className="mt-1 font-mono text-xs">Details: {details}</p>}
96
  </div>
97
  )}
98
+ {error === "no_code" && "No authorization code received."}
99
  {error === "callback_failed" && (
100
  <div>
101
  <p>Failed to complete authentication.</p>
102
  {message && <p className="mt-1 font-mono text-xs">Error: {message}</p>}
103
  </div>
104
  )}
105
+ {error === "hf_oauth_not_configured" && (
106
  <div>
107
+ <p>HuggingFace OAuth is not properly configured.</p>
108
+ <p className="mt-1">Please set up the required environment variables.</p>
109
  </div>
110
  )}
111
+ {error === "hf_oauth_failed" && (
112
  <div>
113
+ <p>HuggingFace authentication failed.</p>
114
+ {details && <p className="mt-1 font-mono text-xs">Details: {details}</p>}
115
  </div>
116
  )}
117
  </div>
 
120
  </div>
121
  )}
122
 
123
+ {/* Main Account Linking Interface */}
124
+ <div className="bg-white rounded-lg shadow-lg p-8 w-full">
125
+ <h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">
126
+ Account Status
127
+ </h2>
128
+
129
+ <div className="grid grid-cols-2 gap-8 mb-8">
130
+ {/* GitHub Authentication Status */}
131
+ <div className="border rounded-lg p-6 flex flex-col items-center">
132
+ <div className={`p-3 rounded-full ${hasGitHub ? 'bg-green-100' : 'bg-gray-100'} mb-4`}>
133
+ <svg
134
+ className={`w-8 h-8 ${hasGitHub ? 'text-green-600' : 'text-gray-400'}`}
135
+ fill="currentColor"
136
+ viewBox="0 0 24 24"
 
 
 
 
 
 
 
 
 
137
  >
138
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
139
+ </svg>
140
+ </div>
141
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">GitHub</h3>
142
+
143
+ {hasGitHub ? (
144
+ <div className="text-center">
145
+ <div className="flex items-center justify-center mb-2">
146
+ {userSession?.github?.avatar_url && (
147
+ <img
148
+ src={userSession.github.avatar_url}
149
+ alt={userSession.github.login}
150
+ className="w-12 h-12 rounded-full mr-3"
151
+ />
152
+ )}
153
+ <div>
154
+ <p className="font-medium text-gray-900">{userSession?.github?.name || userSession?.github?.login}</p>
155
+ <p className="text-sm text-gray-600">@{userSession?.github?.login}</p>
156
+ </div>
157
+ </div>
158
+ <div className="mt-4">
159
+ <Link
160
+ to="/auth/logout?service=github"
161
+ className="text-sm text-red-600 hover:text-red-800"
162
+ >
163
+ Disconnect
164
+ </Link>
165
+ </div>
166
+ </div>
167
+ ) : (
168
+ <div className="text-center">
169
+ <p className="text-gray-500 mb-4">Not connected</p>
170
+ <Link
171
+ to="/auth/github"
172
+ className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gray-900 hover:bg-gray-800 focus:outline-none"
173
+ >
174
+ <svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
175
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
176
+ </svg>
177
+ Connect GitHub
178
+ </Link>
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ {/* HuggingFace Authentication Status */}
184
+ <div className="border rounded-lg p-6 flex flex-col items-center">
185
+ <div className={`p-3 rounded-full ${hasHuggingFace ? 'bg-green-100' : 'bg-gray-100'} mb-4`}>
186
+ <svg
187
+ className={`w-8 h-8 ${hasHuggingFace ? 'text-green-600' : 'text-gray-400'}`}
188
+ viewBox="0 0 24 24"
189
+ fill="currentColor"
190
  >
191
+ <path d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 2.25c2.387 0 4.5 1.773 4.5 4.5S14.387 13.5 12 13.5 7.5 11.727 7.5 9s2.113-4.5 4.5-4.5zm0 16.5a9.26 9.26 0 01-6.75-2.798c.042-2.233 4.5-3.452 6.75-3.452s6.708 1.219 6.75 3.452A9.26 9.26 0 0112 21z" />
192
+ </svg>
193
  </div>
194
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">HuggingFace</h3>
195
+
196
+ {hasHuggingFace ? (
197
+ <div className="text-center">
198
+ <div className="flex items-center justify-center mb-2">
199
+ {userSession?.huggingface?.avatarUrl && (
200
+ <img
201
+ src={userSession.huggingface.avatarUrl}
202
+ alt={userSession.huggingface.username}
203
+ className="w-12 h-12 rounded-full mr-3"
204
+ />
205
+ )}
206
+ <div>
207
+ <p className="font-medium text-gray-900">{userSession?.huggingface?.fullName || userSession?.huggingface?.username}</p>
208
+ <p className="text-sm text-gray-600">@{userSession?.huggingface?.username}</p>
209
+ </div>
210
+ </div>
211
+ <div className="mt-4">
212
+ <Link
213
+ to="/auth/logout?service=huggingface"
214
+ className="text-sm text-red-600 hover:text-red-800"
215
+ >
216
+ Disconnect
217
+ </Link>
218
+ </div>
219
+ </div>
220
+ ) : (
221
+ <div className="text-center">
222
+ <p className="text-gray-500 mb-4">Not connected</p>
223
+ <Link
224
+ to="/auth/huggingface"
225
+ className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none"
226
+ >
227
+ <svg
228
+ className="w-4 h-4 mr-2"
229
+ fill="currentColor"
230
+ viewBox="0 0 24 24"
231
+ >
232
+ <path d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm0 2.25c2.387 0 4.5 1.773 4.5 4.5S14.387 13.5 12 13.5 7.5 11.727 7.5 9s2.113-4.5 4.5-4.5zm0 16.5a9.26 9.26 0 01-6.75-2.798c.042-2.233 4.5-3.452 6.75-3.452s6.708 1.219 6.75 3.452A9.26 9.26 0 0112 21z" />
233
+ </svg>
234
+ Connect HuggingFace
235
+ </Link>
236
+ </div>
237
+ )}
238
  </div>
239
  </div>
240
+
241
+ {/* Account Linking Status */}
242
+ <div className="border rounded-lg p-6 mb-6">
243
+ <div className="flex items-center justify-between mb-4">
244
+ <h3 className="text-lg font-semibold text-gray-900">Account Linking Status</h3>
245
+ <div className={`px-3 py-1 rounded-full text-sm font-medium ${isLinked ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
246
+ {isLinked ? 'Linked' : 'Not Linked'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  </div>
248
  </div>
249
+
250
+ {isLinked ? (
251
+ <div className="text-center bg-green-50 p-4 rounded-md">
252
+ <svg className="w-10 h-10 text-green-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
253
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
254
+ </svg>
255
+ <p className="text-green-800 font-medium">Your GitHub and HuggingFace accounts are linked!</p>
256
+ <p className="text-sm text-green-700 mt-1">Linked on: {new Date(userSession?.linkedAt || '').toLocaleString()}</p>
257
+
258
+ <div className="mt-4">
259
+ <button
260
+ onClick={() => {
261
+ if (confirm("Are you sure you want to unlink your accounts?")) {
262
+ window.location.href = "/auth/logout?unlink=true";
263
+ }
264
+ }}
265
+ className="text-sm text-red-600 hover:text-red-800 underline"
266
+ >
267
+ Unlink Accounts
268
+ </button>
269
+ </div>
270
+ </div>
271
+ ) : (
272
+ <div className="text-center">
273
+ {hasGitHub && hasHuggingFace ? (
274
+ <div className="bg-yellow-50 p-4 rounded-md">
275
+ <svg className="w-10 h-10 text-yellow-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
276
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
277
+ </svg>
278
+ <p className="text-yellow-800 font-medium">Your accounts are not linked</p>
279
+ <p className="text-sm text-yellow-700 mt-1">You have both accounts connected but they couldn't be linked automatically.</p>
280
+ <p className="text-sm text-yellow-700 mt-1">This may be because one of these accounts is already linked to another account.</p>
281
+
282
+ <div className="mt-4">
283
+ <button
284
+ onClick={() => {
285
+ if (confirm("Try to link your accounts? This will refresh your authentication.")) {
286
+ window.location.href = "/auth/github?link=true";
287
+ }
288
+ }}
289
+ className="text-sm text-blue-600 hover:text-blue-800 underline"
290
+ >
291
+ Try Manual Linking
292
+ </button>
293
+ </div>
294
+ </div>
295
+ ) : (
296
+ <div className="bg-gray-50 p-4 rounded-md">
297
+ <p className="text-gray-700">
298
+ Connect both your GitHub and HuggingFace accounts to link them.
299
+ </p>
300
+ </div>
301
+ )}
302
+ </div>
303
+ )}
304
  </div>
 
305
 
306
+ {/* Stats */}
307
+ {linkingStats && (
308
+ <div className="text-center text-sm text-gray-500 mt-4">
309
+ <p>Total linked accounts: {linkingStats.totalLinks}</p>
310
+ <p>Last updated: {new Date(linkingStats.lastModified).toLocaleString()}</p>
 
 
311
  </div>
312
+ )}
313
+ </div>
314
+
315
+ {/* Quick Info */}
316
+ <div className="grid md:grid-cols-3 gap-6 w-full">
317
+ <div className="bg-white rounded-lg shadow p-6">
318
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">Why Link Accounts?</h3>
319
  <p className="text-gray-600 text-sm">
320
+ Linking your GitHub and HuggingFace accounts enables seamless integration between the two platforms.
321
  </p>
322
  </div>
323
 
324
  <div className="bg-white rounded-lg shadow p-6">
325
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">How It Works</h3>
 
 
 
 
 
326
  <p className="text-gray-600 text-sm">
327
+ Simply connect both accounts and we'll automatically link them for you. Your data is stored securely.
328
  </p>
329
  </div>
330
 
331
  <div className="bg-white rounded-lg shadow p-6">
332
+ <h3 className="text-lg font-semibold text-gray-900 mb-2">Privacy</h3>
 
 
 
 
 
333
  <p className="text-gray-600 text-sm">
334
+ We only store the minimum required information to link your accounts. No sensitive data is kept.
335
  </p>
336
  </div>
337
  </div>
 
339
  </div>
340
  </div>
341
  );
342
+ }
 
 
app/routes/auth.github.callback.tsx CHANGED
@@ -2,6 +2,7 @@ import { redirect } from "@remix-run/node";
2
  import type { LoaderFunctionArgs } from "@remix-run/node";
3
  import { githubApp } from "~/lib/github-app.server";
4
  import { getSession, commitSession } from "~/lib/session.server";
 
5
 
6
  export async function loader({ request }: LoaderFunctionArgs) {
7
  const url = new URL(request.url);
@@ -11,7 +12,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
11
  const installation_id = url.searchParams.get("installation_id");
12
  const setup_action = url.searchParams.get("setup_action");
13
 
14
- console.log('πŸ”„ OAuth callback received');
15
  if (error) {
16
  console.log('- Error:', error);
17
  }
@@ -33,18 +34,53 @@ export async function loader({ request }: LoaderFunctionArgs) {
33
  try {
34
  // Exchange code for access token and get user info
35
  const userAuth = await githubApp.handleCallback(code, state || undefined);
 
36
 
37
- // Create user session
38
  const session = await getSession(request.headers.get("Cookie"));
39
- session.set("user", {
 
 
 
40
  userId: userAuth.id.toString(),
41
  login: userAuth.login,
42
  name: userAuth.name,
43
  email: userAuth.email,
44
  avatar_url: userAuth.avatar_url,
45
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- const redirectUrl = installation_id ? "/install" : "/dashboard";
48
 
49
  return redirect(redirectUrl, {
50
  headers: {
 
2
  import type { LoaderFunctionArgs } from "@remix-run/node";
3
  import { githubApp } from "~/lib/github-app.server";
4
  import { getSession, commitSession } from "~/lib/session.server";
5
+ import { accountLinkingService } from "~/lib/account-linking.server";
6
 
7
  export async function loader({ request }: LoaderFunctionArgs) {
8
  const url = new URL(request.url);
 
12
  const installation_id = url.searchParams.get("installation_id");
13
  const setup_action = url.searchParams.get("setup_action");
14
 
15
+ console.log('πŸ”„ GitHub OAuth callback received');
16
  if (error) {
17
  console.log('- Error:', error);
18
  }
 
34
  try {
35
  // Exchange code for access token and get user info
36
  const userAuth = await githubApp.handleCallback(code, state || undefined);
37
+ console.log('βœ… GitHub OAuth successful for user:', userAuth.login);
38
 
39
+ // Get existing session
40
  const session = await getSession(request.headers.get("Cookie"));
41
+ let userSession = session.get('user') || { isLinked: false };
42
+
43
+ // Add GitHub info to session
44
+ userSession.github = {
45
  userId: userAuth.id.toString(),
46
  login: userAuth.login,
47
  name: userAuth.name,
48
  email: userAuth.email,
49
  avatar_url: userAuth.avatar_url,
50
+ };
51
+
52
+ // Check if we can link accounts (if HuggingFace auth exists)
53
+ if (userSession.huggingface) {
54
+ const linkCheck = accountLinkingService.canLink(
55
+ userAuth.id.toString(),
56
+ userSession.huggingface.username
57
+ );
58
+
59
+ if (linkCheck.canLink) {
60
+ // Create account link
61
+ const accountLink = accountLinkingService.createLink(
62
+ userAuth.id.toString(),
63
+ userAuth.login,
64
+ userSession.huggingface.username
65
+ );
66
+
67
+ userSession.isLinked = true;
68
+ userSession.linkedAt = accountLink.linkedAt;
69
+
70
+ console.log(`πŸ”— Accounts automatically linked: ${userAuth.login} ↔ ${userSession.huggingface.username}`);
71
+ } else {
72
+ console.warn('⚠️ Cannot link accounts:', linkCheck.reason);
73
+ userSession.isLinked = false;
74
+ }
75
+ } else {
76
+ console.log('ℹ️ No HuggingFace authentication found, GitHub auth saved for later linking');
77
+ userSession.isLinked = false;
78
+ }
79
+
80
+ // Save updated session
81
+ session.set('user', userSession);
82
 
83
+ const redirectUrl = installation_id ? "/install" : "/";
84
 
85
  return redirect(redirectUrl, {
86
  headers: {
app/routes/auth.huggingface.callback.tsx ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "@remix-run/node";
2
+ import type { LoaderFunctionArgs } from "@remix-run/node";
3
+ import { huggingFaceOAuth } from "~/lib/huggingface-oauth.server";
4
+ import { parseCookies } from "~/lib/oauth-utils.server";
5
+ import { getSession, commitSession } from "~/lib/session.server";
6
+ import { accountLinkingService } from "~/lib/account-linking.server";
7
+
8
+ export async function loader({ request }: LoaderFunctionArgs) {
9
+ console.log("πŸ”₯ HuggingFace OAuth callback route hit");
10
+
11
+ const url = new URL(request.url);
12
+ const code = url.searchParams.get("code");
13
+ const state = url.searchParams.get("state");
14
+ const error = url.searchParams.get("error");
15
+ const errorDescription = url.searchParams.get("error_description");
16
+
17
+ console.log("Callback params:", {
18
+ code: code ? `${code.substring(0, 4)}...` : null,
19
+ state: state ? `${state.substring(0, 4)}...` : null,
20
+ error,
21
+ errorDescription
22
+ });
23
+
24
+ // Handle OAuth errors
25
+ if (error) {
26
+ console.error("HuggingFace OAuth error:", error);
27
+ if (errorDescription) {
28
+ console.error("Error description:", errorDescription);
29
+ }
30
+
31
+ const redirectUrl = `/?error=hf_oauth_failed&details=${encodeURIComponent(
32
+ errorDescription ? `${error}: ${errorDescription}` : error
33
+ )}`;
34
+
35
+ console.log("Redirecting to:", redirectUrl);
36
+ return redirect(redirectUrl);
37
+ }
38
+
39
+ if (!code || !state) {
40
+ console.error("Missing code or state in HuggingFace OAuth callback");
41
+ return redirect("/?error=hf_missing_params");
42
+ }
43
+
44
+ // Get stored PKCE data from cookies
45
+ const cookieHeader = request.headers.get("Cookie");
46
+ const cookies = parseCookies(cookieHeader || "");
47
+
48
+ console.log("Cookie keys available:", Object.keys(cookies));
49
+
50
+ const storedState = cookies.hf_oauth_state;
51
+ const codeVerifier = cookies.hf_oauth_code_verifier;
52
+ const returnTo = cookies.hf_oauth_return_to
53
+ ? decodeURIComponent(cookies.hf_oauth_return_to)
54
+ : "/";
55
+
56
+ console.log("Cookie values:", {
57
+ storedState: storedState ? `${storedState.substring(0, 4)}...` : null,
58
+ codeVerifier: codeVerifier ? `length: ${codeVerifier.length}` : null,
59
+ returnTo
60
+ });
61
+
62
+ // Verify state parameter (CSRF protection)
63
+ if (!storedState || storedState !== state) {
64
+ console.error("HuggingFace OAuth state mismatch");
65
+ console.error("Stored state:", storedState ? storedState.substring(0, 10) + "..." : "null");
66
+ console.error("Received state:", state ? state.substring(0, 10) + "..." : "null");
67
+ return redirect("/?error=hf_state_mismatch");
68
+ }
69
+
70
+ if (!codeVerifier) {
71
+ console.error("Missing code verifier for HuggingFace OAuth");
72
+ return redirect("/?error=hf_missing_verifier");
73
+ }
74
+
75
+ try {
76
+ console.log("πŸ”„ Attempting to complete OAuth flow with code and verifier");
77
+
78
+ // Complete OAuth flow
79
+ const { accessToken, userInfo } = await huggingFaceOAuth.completeOAuthFlow(
80
+ code,
81
+ codeVerifier
82
+ );
83
+
84
+ console.log("βœ… HuggingFace OAuth successful for user:", userInfo.username);
85
+
86
+ // Get existing session
87
+ const session = await getSession(request.headers.get("Cookie"));
88
+ let userSession = session.get("user") || { isLinked: false };
89
+
90
+ // Add HuggingFace info to session
91
+ userSession.huggingface = userInfo;
92
+
93
+ // Check if we can link accounts (if GitHub auth exists)
94
+ if (userSession.github) {
95
+ const linkCheck = accountLinkingService.canLink(
96
+ userSession.github.userId,
97
+ userInfo.username
98
+ );
99
+
100
+ if (linkCheck.canLink) {
101
+ // Create account link
102
+ const accountLink = accountLinkingService.createLink(
103
+ userSession.github.userId,
104
+ userSession.github.login,
105
+ userInfo.username
106
+ );
107
+
108
+ userSession.isLinked = true;
109
+ userSession.linkedAt = accountLink.linkedAt;
110
+
111
+ console.log(
112
+ `πŸ”— Accounts automatically linked: ${userSession.github.login} ↔ ${userInfo.username}`
113
+ );
114
+ } else {
115
+ console.warn("⚠️ Cannot link accounts:", linkCheck.reason);
116
+ userSession.isLinked = false;
117
+ }
118
+ } else {
119
+ console.log(
120
+ "ℹ️ No GitHub authentication found, HuggingFace auth saved for later linking"
121
+ );
122
+ userSession.isLinked = false;
123
+ }
124
+
125
+ // Save updated session
126
+ session.set("user", userSession);
127
+
128
+ // Create response with updated session cookie
129
+ const response = redirect(returnTo);
130
+
131
+ // Clear OAuth temporary cookies
132
+ const clearCookieOptions = "Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
133
+ response.headers.append(
134
+ "Set-Cookie",
135
+ `hf_oauth_state=; ${clearCookieOptions}`
136
+ );
137
+ response.headers.append(
138
+ "Set-Cookie",
139
+ `hf_oauth_code_verifier=; ${clearCookieOptions}`
140
+ );
141
+ response.headers.append(
142
+ "Set-Cookie",
143
+ `hf_oauth_return_to=; ${clearCookieOptions}`
144
+ );
145
+
146
+ // Set session cookie
147
+ response.headers.append("Set-Cookie", await commitSession(session));
148
+
149
+ console.log("βœ… Redirecting to:", returnTo);
150
+ return response;
151
+ } catch (error: any) {
152
+ console.error("HuggingFace OAuth callback error:", error);
153
+ const errorMessage = error.message || "Unknown error";
154
+ console.error("Error details:", errorMessage);
155
+
156
+ return redirect(
157
+ `/?error=hf_callback_failed&message=${encodeURIComponent(errorMessage)}`
158
+ );
159
+ }
160
+ }
app/routes/auth.huggingface.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "@remix-run/node";
2
+ import type { LoaderFunctionArgs } from "@remix-run/node";
3
+ import { huggingFaceOAuth } from "~/lib/huggingface-oauth.server";
4
+ import { generateRandomString, createCodeChallenge } from "~/lib/oauth-utils.server";
5
+ import { getUserSession } from "~/lib/session.server";
6
+
7
+ export async function loader({ request }: LoaderFunctionArgs) {
8
+ console.log('πŸ”₯ HuggingFace OAuth login route hit');
9
+
10
+ // Check if HuggingFace OAuth is configured
11
+ if (!huggingFaceOAuth.isConfigured()) {
12
+ console.error('❌ HuggingFace OAuth not configured');
13
+ return redirect('/?error=hf_oauth_not_configured');
14
+ }
15
+
16
+ const url = new URL(request.url);
17
+ const returnTo = url.searchParams.get('returnTo') || '/';
18
+
19
+ // Check for existing user session to see if account linking is in progress
20
+ const userSession = await getUserSession(request);
21
+ if (userSession?.huggingface) {
22
+ console.log('ℹ️ User already has HuggingFace auth in session');
23
+
24
+ // Check if this is an account linking flow
25
+ if (returnTo.includes('link=true')) {
26
+ console.log('πŸ”„ Re-authenticating for account linking purposes');
27
+ } else {
28
+ // Already authenticated with HF, redirect to return URL
29
+ console.log('βœ… Already authenticated with HuggingFace, redirecting');
30
+ return redirect(returnTo);
31
+ }
32
+ }
33
+
34
+ // Generate PKCE parameters for security
35
+ const state = generateRandomString(32);
36
+ const codeVerifier = generateRandomString(128);
37
+ const codeChallenge = await createCodeChallenge(codeVerifier);
38
+
39
+ // Get authorization URL
40
+ const authUrl = huggingFaceOAuth.getAuthorizationUrl(state, codeChallenge);
41
+
42
+ console.log('πŸ”„ Redirecting to HuggingFace OAuth authorization URL');
43
+
44
+ // Create response with redirect and secure cookies for PKCE
45
+ const response = redirect(authUrl);
46
+
47
+ // Store PKCE data and return URL in secure HttpOnly cookies
48
+ const cookieOptions = 'Path=/; HttpOnly; SameSite=Lax; Max-Age=600'; // 10 minutes
49
+
50
+ response.headers.append('Set-Cookie', `hf_oauth_state=${state}; ${cookieOptions}`);
51
+ response.headers.append('Set-Cookie', `hf_oauth_code_verifier=${codeVerifier}; ${cookieOptions}`);
52
+ response.headers.append('Set-Cookie', `hf_oauth_return_to=${encodeURIComponent(returnTo)}; ${cookieOptions}`);
53
+
54
+ console.log('βœ… HuggingFace OAuth cookies set');
55
+
56
+ return response;
57
+ }
app/routes/auth.logout.tsx CHANGED
@@ -1,17 +1,95 @@
1
  import { redirect } from "@remix-run/node";
2
- import type { ActionFunctionArgs } from "@remix-run/node";
3
- import { getSession, destroySession } from "~/lib/session.server";
 
4
 
5
- export async function action({ request }: ActionFunctionArgs) {
 
 
 
 
6
  const session = await getSession(request.headers.get("Cookie"));
 
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  return redirect("/", {
9
  headers: {
10
  "Set-Cookie": await destroySession(session),
11
  },
12
  });
13
- }
14
-
15
- export async function loader() {
16
- return redirect("/");
17
- }
 
1
  import { redirect } from "@remix-run/node";
2
+ import type { LoaderFunctionArgs } from "@remix-run/node";
3
+ import { getSession, commitSession, destroySession } from "~/lib/session.server";
4
+ import { accountLinkingService } from "~/lib/account-linking.server";
5
 
6
+ export async function loader({ request }: LoaderFunctionArgs) {
7
+ const url = new URL(request.url);
8
+ const service = url.searchParams.get("service");
9
+ const unlink = url.searchParams.get("unlink") === "true";
10
+
11
  const session = await getSession(request.headers.get("Cookie"));
12
+ const userSession = session.get("user");
13
 
14
+ // If no user session, just redirect to home
15
+ if (!userSession) {
16
+ return redirect("/");
17
+ }
18
+
19
+ // Handle account unlinking
20
+ if (unlink && userSession.isLinked && userSession.github) {
21
+ console.log(`πŸ”„ Unlinking accounts for GitHub user: ${userSession.github.login}`);
22
+ try {
23
+ // Remove the link from storage
24
+ accountLinkingService.removeLink(userSession.github.userId);
25
+
26
+ // Update session to reflect unlinked status
27
+ userSession.isLinked = false;
28
+ delete userSession.linkedAt;
29
+
30
+ // Save updated session
31
+ session.set("user", userSession);
32
+
33
+ return redirect("/?message=accounts_unlinked", {
34
+ headers: {
35
+ "Set-Cookie": await commitSession(session),
36
+ },
37
+ });
38
+ } catch (error) {
39
+ console.error("Error unlinking accounts:", error);
40
+ return redirect("/?error=unlink_failed");
41
+ }
42
+ }
43
+
44
+ // Handle selective logout by service
45
+ if (service) {
46
+ console.log(`πŸ”„ Logging out of ${service} service`);
47
+
48
+ if (service === "github" && userSession.github) {
49
+ // Remove GitHub info but keep HuggingFace if present
50
+ delete userSession.github;
51
+
52
+ // If linked, update linking status
53
+ if (userSession.isLinked) {
54
+ userSession.isLinked = false;
55
+ delete userSession.linkedAt;
56
+ }
57
+
58
+ // Save updated session
59
+ session.set("user", userSession);
60
+
61
+ return redirect("/", {
62
+ headers: {
63
+ "Set-Cookie": await commitSession(session),
64
+ },
65
+ });
66
+ }
67
+
68
+ if (service === "huggingface" && userSession.huggingface) {
69
+ // Remove HuggingFace info but keep GitHub if present
70
+ delete userSession.huggingface;
71
+
72
+ // If linked, update linking status
73
+ if (userSession.isLinked) {
74
+ userSession.isLinked = false;
75
+ delete userSession.linkedAt;
76
+ }
77
+
78
+ // Save updated session
79
+ session.set("user", userSession);
80
+
81
+ return redirect("/", {
82
+ headers: {
83
+ "Set-Cookie": await commitSession(session),
84
+ },
85
+ });
86
+ }
87
+ }
88
+
89
+ // Full logout (destroy entire session)
90
  return redirect("/", {
91
  headers: {
92
  "Set-Cookie": await destroySession(session),
93
  },
94
  });
95
+ }