drbh
commited on
Commit
Β·
dc06026
1
Parent(s):
6a11e08
feat: refactor for linking
Browse files- app/lib/account-linking.server.ts +218 -0
- app/lib/huggingface-oauth.server.ts +176 -0
- app/lib/oauth-utils.server.ts +43 -0
- app/lib/session.server.ts +15 -1
- app/routes/_index.tsx +243 -104
- app/routes/auth.github.callback.tsx +41 -5
- app/routes/auth.huggingface.callback.tsx +160 -0
- app/routes/auth.huggingface.tsx +57 -0
- app/routes/auth.logout.tsx +86 -8
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
|
| 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: "
|
| 9 |
-
{ name: "description", content: "GitHub
|
| 10 |
];
|
| 11 |
};
|
| 12 |
|
| 13 |
export async function loader({ request }: LoaderFunctionArgs) {
|
| 14 |
-
const
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
export default function Index() {
|
| 24 |
-
const {
|
| 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="
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
<h1 className="text-4xl font-bold text-gray-900">
|
| 42 |
-
|
| 43 |
</h1>
|
| 44 |
<p className="text-lg text-gray-600 text-center">
|
| 45 |
-
GitHub
|
| 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
|
| 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 === "
|
| 77 |
<div>
|
| 78 |
-
<p>
|
| 79 |
-
<p className="mt-1">Please
|
| 80 |
</div>
|
| 81 |
)}
|
| 82 |
-
{error === "
|
| 83 |
<div>
|
| 84 |
-
<p>
|
| 85 |
-
<p className="mt-1">
|
| 86 |
</div>
|
| 87 |
)}
|
| 88 |
</div>
|
|
@@ -91,106 +120,218 @@ export default function Index() {
|
|
| 91 |
</div>
|
| 92 |
)}
|
| 93 |
|
| 94 |
-
{/* Main
|
| 95 |
-
|
| 96 |
-
<
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 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 |
-
|
| 119 |
-
</
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
>
|
| 124 |
-
|
| 125 |
-
</
|
| 126 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 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 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
<
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
<p className="text-gray-600 text-sm">
|
| 169 |
-
|
| 170 |
</p>
|
| 171 |
</div>
|
| 172 |
|
| 173 |
<div className="bg-white rounded-lg shadow p-6">
|
| 174 |
-
<
|
| 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 |
-
|
| 182 |
</p>
|
| 183 |
</div>
|
| 184 |
|
| 185 |
<div className="bg-white rounded-lg shadow p-6">
|
| 186 |
-
<
|
| 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 |
-
|
| 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 |
-
//
|
| 38 |
const session = await getSession(request.headers.get("Cookie"));
|
| 39 |
-
session.
|
|
|
|
|
|
|
|
|
|
| 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" : "/
|
| 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 {
|
| 3 |
-
import { getSession, destroySession } from "~/lib/session.server";
|
|
|
|
| 4 |
|
| 5 |
-
export async function
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|