🔒 Add client credentials management functionality

This commit is contained in:
2026-01-28 18:31:58 +00:00
parent c723496e69
commit 8f7f9915e1

View File

@@ -0,0 +1,863 @@
<template>
<div class="client-credentials-manager">
<div class="container">
<div class="header">
<router-link to="/challonge-test" class="back-button">
Back to Challonge Test
</router-link>
<h1>Client Credentials Manager</h1>
</div>
<!-- Current Status -->
<div class="section">
<h2>Current Status</h2>
<div v-if="hasCredentials && isAuthenticated" class="status success">
<div class="status-icon"></div>
<div class="status-content">
<p><strong>Client Credentials Active</strong></p>
<p class="credentials-display">Client ID: {{ maskedClientId }}</p>
<p class="token-info">
Token expires in: {{ tokenInfo?.expiresIn || 0 }} seconds
</p>
<div class="button-group">
<button @click="handleRefresh" class="btn btn-primary" :disabled="loading">
{{ loading ? 'Refreshing...' : 'Refresh Token' }}
</button>
<button @click="handleLogout" class="btn btn-secondary">
Logout
</button>
<button @click="showDeleteConfirm = true" class="btn btn-danger">
Clear Credentials
</button>
</div>
</div>
</div>
<div v-else-if="hasCredentials" class="status warning">
<div class="status-icon"></div>
<div class="status-content">
<p><strong>Credentials Stored (Not Authenticated)</strong></p>
<p class="credentials-display">Client ID: {{ maskedClientId }}</p>
<button
@click="handleAuthenticate"
class="btn btn-primary"
:disabled="loading"
>
{{ loading ? 'Authenticating...' : 'Get Access Token' }}
</button>
<button @click="showDeleteConfirm = true" class="btn btn-danger">
Clear Credentials
</button>
</div>
</div>
<div v-else class="status warning">
<div class="status-icon"></div>
<div class="status-content">
<p><strong>No Client Credentials Stored</strong></p>
<p>Add your OAuth client credentials below to get started</p>
</div>
</div>
</div>
<!-- Add/Update Credentials -->
<div class="section">
<div class="section-header">
<h2>{{ hasCredentials ? 'Update' : 'Add' }} Client Credentials</h2>
<button
@click="showGuide = true"
class="help-btn"
title="How to get client credentials"
>
Need Help?
</button>
</div>
<div class="form-group">
<label for="client-id">OAuth Client ID</label>
<input
id="client-id"
v-model="inputClientId"
type="text"
placeholder="Enter your OAuth Client ID"
class="form-input"
/>
</div>
<div class="form-group">
<label for="client-secret">OAuth Client Secret</label>
<div class="input-wrapper">
<input
id="client-secret"
v-model="inputClientSecret"
:type="showPassword ? 'text' : 'password'"
placeholder="Enter your OAuth Client Secret"
class="form-input"
/>
<button
@click="showPassword = !showPassword"
class="toggle-password"
:title="showPassword ? 'Hide' : 'Show'"
>
{{ showPassword ? '👁️' : '👁️‍🗨️' }}
</button>
</div>
</div>
<div class="form-group">
<label for="scope">OAuth Scope</label>
<select id="scope" v-model="selectedScope" class="form-input">
<option value="application:manage">
application:manage (Full access to app tournaments)
</option>
<option value="application:organizer">
application:organizer (User's app tournaments)
</option>
<option value="application:player">
application:player (Register and report scores)
</option>
<option value="tournaments:read tournaments:write">
tournaments:read tournaments:write (Standard access)
</option>
</select>
<p class="help-text">
<strong>application:manage</strong> is required for APPLICATION scope
tournament access
</p>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button
@click="handleSaveCredentials"
:disabled="!inputClientId || !inputClientSecret || saving"
class="btn btn-primary"
>
{{
saving
? 'Saving...'
: hasCredentials
? 'Update & Authenticate'
: 'Save & Authenticate'
}}
</button>
<div v-if="successMessage" class="success-message">
{{ successMessage }}
</div>
</div>
<!-- Information -->
<div class="section info-section">
<h2> What are Client Credentials?</h2>
<div class="info-content">
<p>
<strong>Client Credentials Flow</strong> is an OAuth 2.0 authentication
method for server-to-server communication. It allows your application to
access resources without user interaction.
</p>
<h3>When to use this:</h3>
<ul>
<li>
<strong>APPLICATION Scope:</strong> Access all tournaments associated
with your OAuth application
</li>
<li>
<strong>Background tasks:</strong> Automated scripts that don't require
user login
</li>
<li>
<strong>Server operations:</strong> Backend services managing tournament
data
</li>
</ul>
<h3>Security Notes:</h3>
<ul>
<li>
🔒 <strong>Keep your client secret private</strong> - Never commit it
to version control
</li>
<li>
🔐 Credentials are stored in your browser's localStorage (not sent to
any server)
</li>
<li>
⚠️ Only use on <strong>trusted devices</strong> - Clear credentials
when done
</li>
<li>
🔄 Tokens expire automatically - Use the refresh button to get a new
one
</li>
</ul>
<h3>Differences from regular OAuth:</h3>
<ul>
<li>
<strong>No user interaction:</strong> Authentication happens
automatically
</li>
<li>
<strong>Uses client secret:</strong> Requires both client ID and secret
</li>
<li>
<strong>Limited scopes:</strong> Only works with application:* scopes
</li>
<li>
<strong>Direct token:</strong> No authorization code exchange needed
</li>
</ul>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div
v-if="showDeleteConfirm"
class="modal-overlay"
@click="showDeleteConfirm = false"
>
<div class="modal" @click.stop>
<h3>Delete Client Credentials?</h3>
<p>
Are you sure you want to clear the stored client credentials and access
token? You'll need to enter them again to use APPLICATION scope.
</p>
<div class="modal-buttons">
<button @click="showDeleteConfirm = false" class="btn btn-secondary">
Cancel
</button>
<button @click="handleDeleteCredentials" class="btn btn-danger">
Delete
</button>
</div>
</div>
</div>
<!-- Help Guide Modal -->
<div v-if="showGuide" class="modal-overlay" @click="showGuide = false">
<div class="modal modal-large" @click.stop>
<div class="modal-header">
<h3>How to Get Client Credentials</h3>
<button @click="showGuide = false" class="close-btn"></button>
</div>
<div class="modal-body">
<div class="guide-step">
<div class="step-number">1</div>
<div class="step-content">
<h4>Go to Challonge Developer Settings</h4>
<p>
Visit
<a
href="https://challonge.com/settings/developer"
target="_blank"
rel="noopener"
>
https://challonge.com/settings/developer
</a>
</p>
</div>
</div>
<div class="guide-step">
<div class="step-number">2</div>
<div class="step-content">
<h4>Create or Select Your OAuth Application</h4>
<p>
If you don't have an application yet, click "Create New
Application" and fill in the details.
</p>
</div>
</div>
<div class="guide-step">
<div class="step-number">3</div>
<div class="step-content">
<h4>Copy Your Client ID and Client Secret</h4>
<p>
Your application page will show both the <strong>Client ID</strong>
and <strong>Client Secret</strong>. Copy both values.
</p>
<p class="warning">
⚠️ <strong>Important:</strong> The client secret is only shown once.
If you lose it, you'll need to regenerate it.
</p>
</div>
</div>
<div class="guide-step">
<div class="step-number">4</div>
<div class="step-content">
<h4>Paste Credentials Here</h4>
<p>
Return to this page, paste both values, select your desired scope,
and click "Save & Authenticate".
</p>
</div>
</div>
<div class="guide-step">
<div class="step-number">5</div>
<div class="step-content">
<h4>Start Using APPLICATION Scope</h4>
<p>
Once authenticated, you can use the APPLICATION scope in the
Challonge Test page to access all tournaments associated with your
application.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useChallongeClientCredentials } from '../composables/useChallongeClientCredentials.js';
const {
hasCredentials,
maskedClientId,
isAuthenticated,
loading,
error,
tokenInfo,
saveCredentials,
clearCredentials,
authenticate,
refresh,
logout
} = useChallongeClientCredentials();
const inputClientId = ref('');
const inputClientSecret = ref('');
const selectedScope = ref('application:manage');
const showPassword = ref(false);
const showDeleteConfirm = ref(false);
const showGuide = ref(false);
const saving = ref(false);
const successMessage = ref('');
async function handleSaveCredentials() {
error.value = '';
successMessage.value = '';
// Validate input
if (!inputClientId.value.trim()) {
error.value = 'Please enter a Client ID';
return;
}
if (!inputClientSecret.value.trim()) {
error.value = 'Please enter a Client Secret';
return;
}
saving.value = true;
try {
// Save credentials
const success = saveCredentials(
inputClientId.value.trim(),
inputClientSecret.value.trim()
);
if (!success) {
error.value = 'Failed to save credentials';
return;
}
// Authenticate immediately
await authenticate(selectedScope.value);
successMessage.value =
'Client credentials saved and authenticated successfully!';
inputClientId.value = '';
inputClientSecret.value = '';
setTimeout(() => {
successMessage.value = '';
}, 3000);
} catch (err) {
error.value = err.message || 'Authentication failed';
} finally {
saving.value = false;
}
}
async function handleAuthenticate() {
error.value = '';
successMessage.value = '';
try {
await authenticate(selectedScope.value);
successMessage.value = 'Authenticated successfully!';
setTimeout(() => {
successMessage.value = '';
}, 3000);
} catch (err) {
error.value = err.message || 'Authentication failed';
}
}
async function handleRefresh() {
error.value = '';
successMessage.value = '';
try {
await refresh(selectedScope.value);
successMessage.value = 'Token refreshed successfully!';
setTimeout(() => {
successMessage.value = '';
}, 3000);
} catch (err) {
error.value = err.message || 'Token refresh failed';
}
}
function handleLogout() {
logout();
successMessage.value = 'Logged out successfully (credentials retained)';
setTimeout(() => {
successMessage.value = '';
}, 3000);
}
function handleDeleteCredentials() {
const success = clearCredentials();
if (success) {
showDeleteConfirm.value = false;
inputClientId.value = '';
inputClientSecret.value = '';
successMessage.value = 'Client credentials cleared successfully';
setTimeout(() => {
successMessage.value = '';
}, 3000);
} else {
error.value = 'Failed to clear client credentials';
}
}
</script>
<style scoped>
.client-credentials-manager {
min-height: 100vh;
padding: 2rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.back-button {
padding: 0.5rem 1rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
transition: all 0.3s ease;
}
.back-button:hover {
background: #5568d3;
transform: translateX(-2px);
}
h1 {
color: #333;
margin: 0;
font-size: 2rem;
}
.section {
margin: 2rem 0;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.help-btn {
padding: 0.5rem 1rem;
background: #fbbf24;
color: #78350f;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.help-btn:hover {
background: #f59e0b;
transform: scale(1.05);
}
h2 {
color: #495057;
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.status {
display: flex;
gap: 1rem;
padding: 1.5rem;
border-radius: 8px;
align-items: flex-start;
}
.status.success {
background: #d1fae5;
border-left: 4px solid #10b981;
}
.status.warning {
background: #fef3c7;
border-left: 4px solid #fbbf24;
}
.status-icon {
font-size: 2rem;
}
.status-content {
flex: 1;
}
.status-content p {
margin: 0.5rem 0;
}
.credentials-display {
font-family: 'Courier New', monospace;
color: #667eea;
font-weight: 600;
}
.token-info {
font-size: 0.9rem;
color: #666;
font-style: italic;
}
.button-group {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 1rem;
transition: all 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.input-wrapper {
position: relative;
}
.toggle-password {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 0.25rem;
}
.help-text {
font-size: 0.875rem;
color: #6c757d;
margin-top: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
padding: 1rem;
background: #fee;
color: #c33;
border-radius: 6px;
border-left: 4px solid #c33;
margin: 1rem 0;
}
.success-message {
padding: 1rem;
background: #d1fae5;
color: #065f46;
border-radius: 6px;
border-left: 4px solid #10b981;
margin: 1rem 0;
}
.info-section {
background: linear-gradient(135deg, #e7f3ff 0%, #dbeafe 100%);
border: 2px solid #0284c7;
}
.info-content h3 {
color: #0c4a6e;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.info-content ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.info-content li {
margin: 0.5rem 0;
line-height: 1.6;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: white;
border-radius: 12px;
padding: 2rem;
max-width: 500px;
width: 100%;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.modal-large {
max-width: 700px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-header h3 {
margin: 0;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #f0f0f0;
color: #333;
}
.modal h3 {
color: #333;
margin-bottom: 1rem;
}
.modal p {
color: #666;
line-height: 1.6;
margin-bottom: 1.5rem;
}
.modal-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.modal-body {
max-height: 60vh;
overflow-y: auto;
}
.guide-step {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e9ecef;
}
.guide-step:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.step-number {
flex-shrink: 0;
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.2rem;
}
.step-content h4 {
margin: 0 0 0.5rem 0;
color: #333;
}
.step-content p {
margin: 0.5rem 0;
color: #666;
line-height: 1.6;
}
.step-content a {
color: #667eea;
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid #667eea;
}
.step-content a:hover {
color: #5568d3;
border-bottom-color: #5568d3;
}
.step-content .warning {
background: #fef3c7;
padding: 0.75rem;
border-radius: 6px;
border-left: 3px solid #fbbf24;
margin-top: 0.5rem;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>