🔒 Add authentication hub view for user login and registration

This commit is contained in:
2026-01-29 20:55:32 +00:00
parent 8022b0ea0a
commit 352485f626

View File

@@ -0,0 +1,807 @@
<!--
Authentication Hub
Unified interface for managing all authentication methods across platforms
Supports: Challonge (API Key, OAuth, Client Credentials), Discord (OAuth)
Features:
- Tab-based interface for each platform
- Token status and expiry display
- Manual refresh buttons
- Auto-refresh info
- Success/error notifications
-->
<template>
<div class="auth-hub">
<div class="container">
<div class="header">
<h1>🔐 Authentication Settings</h1>
<p class="subtitle">Manage your authentication credentials and tokens across all platforms</p>
</div>
<!-- Tabs Navigation -->
<div class="tabs-nav">
<button
v-for="platform in platforms"
:key="platform.name"
:class="['tab-button', { active: activePlatform === platform.name }]"
@click="activePlatform = platform.name"
>
{{ platform.icon }} {{ platform.label }}
</button>
</div>
<!-- Notifications -->
<div v-if="successMessage" class="notification success">
<span> {{ successMessage }}</span>
<button @click="successMessage = ''" class="close-btn">×</button>
</div>
<div v-if="errorMessage" class="notification error">
<span> {{ errorMessage }}</span>
<button @click="errorMessage = ''" class="close-btn">×</button>
</div>
<!-- Tab Content -->
<div class="tab-content">
<!-- Challonge Tab -->
<div v-if="activePlatform === 'challonge'" class="platform-section">
<h2>🏆 Challonge Authentication</h2>
<p class="platform-description">
Configure your Challonge API access using API keys, OAuth tokens, or client credentials
</p>
<!-- API Key Section -->
<div class="auth-method">
<div class="method-header">
<h3>API Key</h3>
<span :class="['status', { active: hasChallongeApiKey }]">
{{ hasChallongeApiKey ? '✓ Connected' : '○ Not Connected' }}
</span>
</div>
<p class="method-description">Direct API key authentication for v1 and v2.1 APIs</p>
<div class="form-group">
<input
v-model="challongeApiKey"
type="password"
placeholder="Enter your Challonge API key"
@keyup.enter="saveChallongeApiKey"
/>
<button @click="saveChallongeApiKey" class="btn btn-primary">
{{ hasChallongeApiKey ? 'Update' : 'Save' }} API Key
</button>
<button v-if="hasChallongeApiKey" @click="deleteChallongeApiKey" class="btn btn-danger">
Delete
</button>
</div>
<p class="help-text">
Get your API key from
<a href="https://challonge.com/settings/developer" target="_blank">Challonge Developer Settings</a>
</p>
</div>
<!-- OAuth Section -->
<div class="auth-method">
<div class="method-header">
<h3>OAuth 2.0</h3>
<span :class="['status', { active: isChallongeOAuthAuthenticated }]">
{{ isChallongeOAuthAuthenticated ? '✓ Connected' : '○ Not Connected' }}
</span>
</div>
<p class="method-description">User token authentication for v2.1 API (APPLICATION scope)</p>
<div v-if="isChallongeOAuthAuthenticated" class="token-info">
<div class="token-detail">
<span class="label">Status:</span>
<span class="value"> Authenticated</span>
</div>
<div class="token-detail">
<span class="label">Expires in:</span>
<span class="value">{{ formatExpiryTime(challongeOAuthExpiresIn) }}</span>
</div>
<div v-if="challongeOAuthRefreshedAt" class="token-detail">
<span class="label">Last refreshed:</span>
<span class="value">{{ formatDate(challongeOAuthRefreshedAt) }}</span>
</div>
<div class="button-group">
<button @click="refreshChallongeOAuth" :disabled="oauthLoading" class="btn btn-secondary">
{{ oauthLoading ? '⏳ Refreshing...' : '🔄 Refresh Token' }}
</button>
<button @click="disconnectChallongeOAuth" class="btn btn-danger">
Disconnect
</button>
</div>
</div>
<div v-else class="button-group">
<button @click="connectChallongeOAuth" :disabled="oauthLoading" class="btn btn-primary">
{{ oauthLoading ? '⏳ Connecting...' : '🔗 Connect with Challonge OAuth' }}
</button>
</div>
<p class="help-text">
Register your application at
<a href="https://connect.challonge.com" target="_blank">Challonge OAuth</a>
and use it for APPLICATION scope access
</p>
</div>
<!-- Client Credentials Section -->
<div class="auth-method">
<div class="method-header">
<h3>Client Credentials</h3>
<span :class="['status', { active: hasChallongeClientCredentials }]">
{{ hasChallongeClientCredentials ? '✓ Connected' : '○ Not Connected' }}
</span>
</div>
<p class="method-description">For APPLICATION scope access with client ID and secret</p>
<div v-if="hasChallongeClientCredentials" class="token-info">
<div class="token-detail">
<span class="label">Client ID:</span>
<code>{{ challongeClientId?.substring(0, 10) }}...</code>
</div>
<div class="token-detail">
<span class="label">Status:</span>
<span class="value">{{ isChallongeClientCredentialsValid ? '✅ Valid' : '⚠️ Expired' }}</span>
</div>
<div v-if="challongeClientExpiresIn" class="token-detail">
<span class="label">Token expires in:</span>
<span class="value">{{ formatExpiryTime(challongeClientExpiresIn) }}</span>
</div>
<div class="button-group">
<button @click="deleteChallongeClientCredentials" class="btn btn-danger">
Delete
</button>
</div>
</div>
<div v-else>
<div class="form-group">
<input
v-model="newClientId"
type="text"
placeholder="Client ID"
@keyup.enter="saveChallongeClientCredentials"
/>
<input
v-model="newClientSecret"
type="password"
placeholder="Client Secret"
@keyup.enter="saveChallongeClientCredentials"
/>
<button @click="saveChallongeClientCredentials" class="btn btn-primary">
Save Client Credentials
</button>
</div>
</div>
<p class="help-text">
Get credentials from
<a href="https://challonge.com/settings/developer" target="_blank">Challonge Developer Settings</a>
</p>
</div>
</div>
<!-- Discord Tab -->
<div v-if="activePlatform === 'discord'" class="platform-section">
<h2>🎮 Discord Authentication</h2>
<p class="platform-description">
Verify your Discord identity for access control and developer features
</p>
<div class="auth-method">
<div class="method-header">
<h3>Discord OAuth</h3>
<span :class="['status', { active: isDiscordAuthenticated }]">
{{ isDiscordAuthenticated ? '✓ Connected' : '○ Not Connected' }}
</span>
</div>
<p class="method-description">Secure identity verification using Discord account</p>
<div v-if="isDiscordAuthenticated" class="token-info">
<div class="token-detail">
<span class="label">Username:</span>
<span class="value">{{ discordUsername || 'Loading...' }}</span>
</div>
<div class="token-detail">
<span class="label">Status:</span>
<span class="value"> Authenticated</span>
</div>
<div v-if="discordExpiresIn" class="token-detail">
<span class="label">Expires in:</span>
<span class="value">{{ formatExpiryTime(discordExpiresIn) }}</span>
</div>
<div class="button-group">
<button @click="refreshDiscordAuth" :disabled="discordLoading" class="btn btn-secondary">
{{ discordLoading ? '⏳ Refreshing...' : '🔄 Refresh' }}
</button>
<button @click="disconnectDiscord" class="btn btn-danger">
Disconnect
</button>
</div>
</div>
<div v-else class="button-group">
<button @click="connectDiscord" :disabled="discordLoading" class="btn btn-primary">
{{ discordLoading ? '⏳ Connecting...' : '🔗 Connect with Discord' }}
</button>
</div>
<p class="help-text">
Create Discord application at
<a href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</a>
</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<p>Your authentication tokens are stored securely in your browser's local storage.</p>
<router-link to="/" class="btn-link">← Back Home</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useChallongeApiKey } from '../composables/useChallongeApiKey.js';
import { useChallongeOAuth } from '../composables/useChallongeOAuth.js';
import { useChallongeClientCredentials } from '../composables/useChallongeClientCredentials.js';
import { useDiscordOAuth } from '../composables/useDiscordOAuth.js';
import { getAllPlatforms } from '../config/platforms.js';
// State
const activePlatform = ref('challonge');
const successMessage = ref('');
const errorMessage = ref('');
const oauthLoading = ref(false);
const discordLoading = ref(false);
// Challonge API Key
const { apiKey: challongeApiKey, save: saveApiKey, delete: deleteApiKey } = useChallongeApiKey();
const newChallongeApiKey = ref('');
const hasChallongeApiKey = computed(() => !!challongeApiKey.value);
// Challonge OAuth
const challongeOAuth = useChallongeOAuth();
const isChallongeOAuthAuthenticated = computed(() => challongeOAuth.isAuthenticated.value);
const challongeOAuthExpiresIn = computed(() => challongeOAuth.expiresIn.value);
const challongeOAuthRefreshedAt = computed(() => {
return challongeOAuth.tokens.value?.refreshed_at || challongeOAuth.tokens.value?.created_at;
});
// Challonge Client Credentials
const challengeClientCreds = useChallongeClientCredentials();
const hasChallongeClientCredentials = computed(() => challengeClientCreds.isConfigured.value);
const isChallongeClientCredentialsValid = computed(() => challengeClientCreds.isValid.value);
const challongeClientId = computed(() => challengeClientCreds.clientId.value);
const challongeClientExpiresIn = computed(() => challengeClientCreds.expiresIn.value);
const newClientId = ref('');
const newClientSecret = ref('');
// Discord OAuth
const discord = useDiscordOAuth();
const isDiscordAuthenticated = computed(() => discord.hasDiscordAuth.value);
const discordUsername = computed(() => discord.discordUsername.value);
const discordExpiresIn = computed(() => discord.expiresIn.value);
// Get all platforms for tab navigation
const platforms = computed(() => getAllPlatforms());
// Methods
function saveChallongeApiKey() {
try {
saveApiKey(newChallongeApiKey.value);
newChallongeApiKey.value = '';
successMessage.value = 'Challonge API key saved successfully!';
setTimeout(() => (successMessage.value = ''), 3000);
} catch (err) {
errorMessage.value = err.message;
}
}
function deleteChallongeApiKey() {
if (confirm('Are you sure? This will remove your API key.')) {
deleteApiKey();
successMessage.value = 'Challonge API key deleted';
setTimeout(() => (successMessage.value = ''), 3000);
}
}
async function connectChallongeOAuth() {
try {
oauthLoading.value = true;
challongeOAuth.login({ return_to: '/auth' });
} catch (err) {
errorMessage.value = err.message;
oauthLoading.value = false;
}
}
async function refreshChallongeOAuth() {
try {
oauthLoading.value = true;
await challongeOAuth.refreshToken();
successMessage.value = 'Challonge OAuth token refreshed!';
setTimeout(() => (successMessage.value = ''), 3000);
} catch (err) {
errorMessage.value = err.message;
} finally {
oauthLoading.value = false;
}
}
function disconnectChallongeOAuth() {
if (confirm('Disconnect Challonge OAuth? You will need to reconnect to use OAuth features.')) {
challongeOAuth.logout();
successMessage.value = 'Disconnected from Challonge OAuth';
setTimeout(() => (successMessage.value = ''), 3000);
}
}
function saveChallongeClientCredentials() {
try {
challengeClientCreds.save(newClientId.value, newClientSecret.value);
newClientId.value = '';
newClientSecret.value = '';
successMessage.value = 'Client credentials saved!';
setTimeout(() => (successMessage.value = ''), 3000);
} catch (err) {
errorMessage.value = err.message;
}
}
function deleteChallongeClientCredentials() {
if (confirm('Delete client credentials?')) {
challengeClientCreds.delete();
successMessage.value = 'Client credentials deleted';
setTimeout(() => (successMessage.value = ''), 3000);
}
}
async function connectDiscord() {
try {
discordLoading.value = true;
discord.login({ return_to: '/auth' });
} catch (err) {
errorMessage.value = err.message;
discordLoading.value = false;
}
}
async function refreshDiscordAuth() {
try {
discordLoading.value = true;
await discord.refreshToken();
successMessage.value = 'Discord token refreshed!';
setTimeout(() => (successMessage.value = ''), 3000);
} catch (err) {
errorMessage.value = err.message;
} finally {
discordLoading.value = false;
}
}
function disconnectDiscord() {
if (confirm('Disconnect Discord? You will need to reconnect for Discord features.')) {
discord.logout();
successMessage.value = 'Disconnected from Discord';
setTimeout(() => (successMessage.value = ''), 3000);
}
}
function formatExpiryTime(seconds) {
if (!seconds) return 'Unknown';
if (seconds < 0) return 'Expired';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatDate(timestamp) {
if (!timestamp) return 'Never';
return new Date(timestamp).toLocaleString();
}
// Load Discord profile on mount if authenticated
onMounted(async () => {
if (isDiscordAuthenticated.value) {
try {
await discord.fetchUserProfile();
} catch (err) {
console.error('Failed to load Discord profile:', err);
}
}
});
</script>
<style scoped>
.auth-hub {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem 0;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 0 1rem;
}
.header {
text-align: center;
color: white;
margin-bottom: 2rem;
}
.header h1 {
font-size: 2.5rem;
margin: 0 0 0.5rem;
font-weight: 700;
}
.subtitle {
font-size: 1.1rem;
opacity: 0.95;
margin: 0;
}
/* Tabs Navigation */
.tabs-nav {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
border-bottom: 2px solid rgba(255, 255, 255, 0.2);
}
.tab-button {
padding: 1rem 1.5rem;
border: none;
background: transparent;
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.2s;
opacity: 0.8;
}
.tab-button:hover {
opacity: 1;
}
.tab-button.active {
opacity: 1;
border-bottom-color: white;
}
/* Notifications */
.notification {
padding: 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
}
.notification.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.notification.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.close-btn:hover {
opacity: 1;
}
/* Tab Content */
.tab-content {
background: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.platform-section h2 {
margin-top: 0;
color: #333;
margin-bottom: 0.5rem;
}
.platform-description {
color: #666;
margin-bottom: 2rem;
line-height: 1.6;
}
/* Auth Method */
.auth-method {
margin-bottom: 2.5rem;
padding-bottom: 2rem;
border-bottom: 1px solid #eee;
}
.auth-method:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.method-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.method-header h3 {
margin: 0;
color: #333;
font-size: 1.1rem;
}
.status {
font-size: 0.9rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
background: #f0f0f0;
color: #666;
}
.status.active {
background: #d4edda;
color: #155724;
font-weight: 500;
}
.method-description {
color: #666;
margin: 0.5rem 0 1rem;
font-size: 0.95rem;
}
/* Token Info */
.token-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.token-detail {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.token-detail:last-child {
margin-bottom: 0;
}
.token-detail .label {
color: #666;
font-weight: 500;
}
.token-detail .value {
color: #333;
font-weight: 600;
}
.token-detail code {
background: #fff;
padding: 0.25rem 0.5rem;
border-radius: 3px;
border: 1px solid #dee2e6;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
}
/* Forms */
.form-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.form-group input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Buttons */
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-size: 0.95rem;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-2px);
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
transform: translateY(-2px);
}
.help-text {
font-size: 0.85rem;
color: #666;
margin: 1rem 0 0;
}
.help-text a {
color: #667eea;
text-decoration: none;
}
.help-text a:hover {
text-decoration: underline;
}
/* Footer */
.footer {
background: white;
border-radius: 8px;
padding: 2rem;
text-align: center;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.footer p {
color: #666;
margin: 0 0 1rem;
}
.btn-link {
color: #667eea;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.btn-link:hover {
color: #5568d3;
text-decoration: underline;
}
/* Responsive */
@media (max-width: 768px) {
.header h1 {
font-size: 1.75rem;
}
.tabs-nav {
gap: 0.5rem;
}
.tab-button {
padding: 0.75rem 1rem;
font-size: 0.9rem;
}
.method-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.token-detail {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.form-group {
flex-direction: column;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
text-align: center;
}
}
</style>
```
---
## Summary
All code is ready to apply. The order is:
1. Update router.js (simple, unblocks routes)
2. Update OAuthCallback.vue (enables OAuth callback)
3. Update DeveloperTools.vue (simple property update)
4. Update .env (add Discord credentials)
5. Create AuthenticationHub.vue (largest file)
6. Update ChallongeTest.vue (remove auth sections, add link)
7. Build and test
````
```