UNPKG

@teamwork/get-bearer-token

Version:

CLI tool to obtain bearer tokens for Teamwork API using OAuth flow

535 lines (479 loc) 22.3 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Teamwork.com - Bearer Token Generator</title> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/axios/dist/axios.min.js"></script> <script src="https://cdn.tailwindcss.com"></script> </head> <body class="bg-gray-50 min-h-screen py-8"> <div id="app" class="w-full max-w-4xl mx-auto px-4"> <div class="bg-white p-8 rounded-xl shadow-lg"> <!-- Header Section --> <div class="text-center mb-8"> <h1 class="text-3xl font-bold text-gray-800 mb-2"> Teamwork Bearer Token Generator <span v-if="appVersion" class="text-sm font-normal text-gray-500 ml-2">v{{ appVersion }}</span> </h1> <p class="text-gray-600">Generate OAuth bearer tokens for your Teamwork developer applications</p> <!-- Sign In Button (shown when not authenticated and not authenticating) --> <div v-if="!authenticated && !loading && !authenticating" class="mt-6"> <button @click="signIn" class="px-8 py-4 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors font-medium text-lg" > Sign in to Teamwork </button> <p class="text-sm text-gray-500 mt-3">You'll be redirected to Teamwork to sign in securely</p> </div> <!-- Authenticating Message (shown when auth window is open) --> <div v-if="!authenticated && !loading && authenticating" class="mt-6"> <div class="text-center p-6 bg-blue-50 rounded-lg"> <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div> <p class="text-blue-800 font-medium text-lg">Signing in to Teamwork...</p> <p class="text-sm text-blue-600 mt-2">Please complete the sign-in process in the popup window</p> </div> </div> <!-- User Info (shown when authenticated) --> <div v-if="authenticated" class="mt-4 p-4 bg-blue-50 rounded-lg"> <div class="flex items-center justify-between"> <div> <div class="text-sm text-gray-700"> <span class="font-medium">Logged in as:</span> {{ user.firstName }} {{ user.lastName }} </div> <a :href="`${baseUrl}developer`" target="_blank" class="text-blue-600 hover:underline font-medium text-sm"> {{ baseUrl }}developer → </a> </div> <button @click="logout" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"> Logout </button> </div> </div> </div> <div v-if="loading" class="text-gray-500 text-center"> <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div> <p class="mt-2">Loading...</p> </div> <div v-else-if="error" class="text-red-500 font-semibold text-center p-4 bg-red-50 rounded-lg"> Error: {{ error }} </div> <div v-else-if="authenticated"> <!-- Create New App Section --> <div class="mb-6"> <div class="flex items-center justify-between mb-4"> <h2 class="text-xl font-semibold text-gray-800">Your Developer Apps</h2> <button @click="showCreateAppModal = true" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors flex items-center space-x-2"> <span>+</span> <span>Create New App</span> </button> </div> </div> <!-- Blank state --> <div v-if="userApps.length === 0" class="text-center text-gray-600 border-2 border-dashed border-gray-300 rounded-lg p-8"> <div class="mb-4"> <svg class="w-16 h-16 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"> </path> </svg> </div> <h3 class="text-lg font-medium text-gray-700 mb-2">No apps found</h3> <p class="mb-4">Create your first developer app to start generating bearer tokens.</p> <button @click="showCreateAppModal = true" class="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"> Create Your First App </button> <div class="mt-6 text-sm text-gray-500"> Or manage existing apps in the <a :href="`${baseUrl}developer`" target="_blank" class="text-blue-600 hover:underline font-medium"> Developer Console </a> </div> </div> <!-- App list --> <div v-if="userApps.length > 0" class="space-y-4"> <div v-for="app in userApps" :key="app.id" class="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow"> <div class="mb-4"> <div> <a :href="`${baseUrl}developer#/apps/${app.id}/`" target="_blank" class="font-semibold text-lg text-blue-600 hover:text-blue-800 hover:underline" title="Open in browser"> {{ app.name }} </a> <p v-if="app.description" class="text-gray-600 text-sm mt-1">{{ app.description }}</p> <div class="flex items-center space-x-4 mt-2 text-xs text-gray-500"> <span>Client ID: {{ app.clientId }}</span> <span v-if="app.scopes && app.scopes.length">Scopes: {{ app.scopes.join(', ') }}</span> </div> </div> </div> <div class="space-y-3"> <!-- Token Display --> <div class="flex items-center space-x-3"> <label class="text-sm font-medium text-gray-700 min-w-[100px]">Bearer Token:</label> <input type="text" :value="app.accessToken || (canGenerateToken(app) ? 'Click Generate to create token...' : '')" readonly class="flex-1 border border-gray-300 rounded-md px-3 py-2 bg-gray-50 text-sm focus:outline-none" :class="app.accessToken ? 'font-mono' : 'italic text-gray-500'" /> <button v-if="!app.accessToken && app.hasRedirectUri" @click="generateTokenForApp(app.id)" :disabled="generatingToken === app.id" class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm whitespace-nowrap" :class="generatingToken === app.id ? 'opacity-50 cursor-not-allowed' : ''"> <span v-if="generatingToken === app.id">Generating...</span> <span v-else>Generate Token</span> </button> <button v-if="app.accessToken" @click="copyToClipboard(app.accessToken)" class="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors text-sm"> Copy </button> </div> <!-- Warning for missing redirect URI --> <div v-if="!app.hasRedirectUri" class="p-4 bg-amber-50 border border-amber-200 rounded-md"> <div class="flex"> <div class="flex-shrink-0"> <svg class="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /> </svg> </div> <div class="ml-3"> <h3 class="text-sm font-medium text-amber-800">Configuration Required</h3> <div class="mt-2 text-sm text-amber-700"> <p>To generate tokens, add this redirect URI to your app settings:</p> <code class="block mt-2 px-2 py-1 bg-amber-100 rounded text-xs font-mono">{{ REDIRECT_URI }}</code> <p class="mt-2"> <a :href="`${baseUrl}developer#/apps/${app.id}/`" target="_blank" class="text-amber-800 underline hover:text-amber-900"> Update app settings → </a> </p> </div> </div> </div> </div> </div> </div> </div> </div> <!-- Create App Modal --> <div v-if="showCreateAppModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div class="bg-white rounded-lg p-6 w-full max-w-md"> <h3 class="text-lg font-semibold mb-4">Create New Developer App</h3> <form @submit.prevent="createNewApp"> <div class="space-y-4"> <div> <label class="block text-sm font-medium text-gray-700 mb-1">App Name *</label> <input type="text" v-model="newApp.name" required class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="My Awesome App" /> </div> <div> <label class="block text-sm font-medium text-gray-700 mb-1">Description</label> <textarea v-model="newApp.description" rows="3" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Brief description of your app"></textarea> </div> <div class="p-3 bg-blue-50 border border-blue-200 rounded-md text-sm"> <div class="flex"> <svg class="h-4 w-4 text-blue-500 mt-0.5 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" /> </svg> <div> <p class="text-blue-700 font-medium">Auto-configured for tokens</p> <p class="text-blue-600 mt-1">This app will be created with 'Projects' scope and the correct redirect URI for generating bearer tokens.</p> </div> </div> </div> </div> <div class="flex justify-end space-x-3 mt-6"> <button type="button" @click="showCreateAppModal = false" class="px-4 py-2 text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"> Cancel </button> <button type="submit" :disabled="creatingApp" class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50"> <span v-if="creatingApp">Creating...</span> <span v-else>Create App</span> </button> </div> </form> </div> </div> <!-- Toast Notifications --> <div class="fixed top-4 right-4 z-50"> <div v-for="toast in toasts" :key="toast.id" class="mb-4 p-4 rounded-lg shadow-lg transition-all duration-300" :class="toast.type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'"> <div class="flex items-center"> <svg v-if="toast.type === 'success'" class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> </svg> <svg v-else class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> </svg> <span>{{ toast.message }}</span> </div> </div> </div> </div> </div> <script> const { createApp, ref, onMounted, computed } = Vue; const REDIRECT_URI = 'http://127.0.0.1:8123'; createApp({ setup() { const authenticated = ref(false); const baseUrl = ref(''); const apps = ref([]); const user = ref(null); const loading = ref(true); const error = ref(null); const generatingToken = ref(null); const authenticating = ref(false); const appVersion = ref(''); // Modal states const showCreateAppModal = ref(false); const creatingApp = ref(false); // New app form const newApp = ref({ name: '', description: '' }); // Toast notifications const toasts = ref([]); const canGenerateToken = (app) => { return !app.accessToken && app.hasRedirectUri; }; const isOwnApp = (app) => { return app.createdBy && app.createdBy.id === user.value.id; }; const userApps = computed(() => { if (!user.value) return []; return apps.value.filter(app => app.createdBy && app.createdBy.id === user.value.id); }); function showToast(message, type = 'success') { const id = Date.now() + Math.random(); toasts.value.push({ id, message, type }); setTimeout(() => { toasts.value = toasts.value.filter(toast => toast.id !== id); }, 4000); } function signIn() { authenticating.value = true; window.location.href = '/api/auth/login'; } async function getApps() { const res = await axios.get('/api/proxy/teamwork/developer/api/v1/apps.json'); return res.data.apps.map((app) => ({ ...app, accessToken: null, hasRedirectUri: app.redirect_uris.filter((url) => url.trim() === REDIRECT_URI).length > 0, })); } async function createToken(code, app) { const res = await axios.post( '/api/auth/app-token', { code, clientId: app.clientId, clientSecret: app.clientSecret, redirectUri: REDIRECT_URI, }, { headers: { 'Content-Type': "application/json", Accept: "application/json", }, } ); return res.data.data; } async function generateTokenForApp(appId) { const app = apps.value.find(a => a.id === appId); generatingToken.value = appId; // Store app context in localStorage so we can resume after OAuth redirect localStorage.setItem('pendingAppToken', JSON.stringify({ appId: app.id, clientId: app.clientId, clientSecret: app.clientSecret, })); // Redirect to Teamwork launchpad with the app's clientId window.location.href = `/api/auth/app-login?clientId=${app.clientId}`; } async function createNewApp() { if (!newApp.value.name.trim()) { showToast('Please enter an app name', 'error'); return; } creatingApp.value = true; try { const response = await axios.post('/api/proxy/teamwork/developer/api/v1/apps.json', { app: { name: newApp.value.name, description: newApp.value.description || null, shortDescription: null, logoUrl: null, scopes: ['projects'], redirect_uris: [REDIRECT_URI] } }); if (response.data && response.data.app) { const createdApp = response.data.app; const newAppData = { ...createdApp, accessToken: null, hasRedirectUri: true }; apps.value.push(newAppData); newApp.value = { name: '', description: '' }; showCreateAppModal.value = false; showToast(`App "${createdApp.name}" created successfully!`, 'success'); } } catch (err) { console.error('Failed to create app:', err); showToast('Failed to create app. Please try again.', 'error'); } finally { creatingApp.value = false; } } function copyToClipboard(token) { if (!token) return; navigator.clipboard.writeText(token) .then(() => showToast('Token copied to clipboard!', 'success')) .catch(() => showToast('Failed to copy token', 'error')); } function openDeveloperPortal(appId) { window.open(`${baseUrl.value}developer#/apps/${appId}/`, '_blank'); } async function logout() { try { await axios.post('/api/auth/logout'); authenticated.value = false; baseUrl.value = ''; apps.value = []; user.value = null; } catch (err) { console.error('Logout error:', err); showToast('Failed to logout', 'error'); } } onMounted(async () => { const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code"); const pendingAppTokenStr = localStorage.getItem('pendingAppToken'); try { if (code) { loading.value = true; // Check if this is an app-specific token generation (stored before redirect) if (pendingAppTokenStr) { const pendingApp = JSON.parse(pendingAppTokenStr); localStorage.removeItem('pendingAppToken'); // Check we're already authenticated const statusResponse = await axios.get('/api/auth/status'); appVersion.value = statusResponse.data.version || ''; if (statusResponse.data.authenticated) { authenticated.value = true; baseUrl.value = statusResponse.data.installation.url; user.value = { id: statusResponse.data.user.id, firstName: statusResponse.data.user.firstName, lastName: statusResponse.data.user.lastName, email: statusResponse.data.user.email }; // Load apps and generate token for the pending app apps.value = await getApps(); const selectedApp = apps.value.find(a => a.id == pendingApp.appId); if (selectedApp) { const tokenData = await createToken(code, selectedApp); selectedApp.accessToken = tokenData.access_token; generatingToken.value = null; showToast(`Bearer token generated for "${selectedApp.name}"!`, 'success'); } window.history.replaceState({}, '', '/'); } } else { // Initial authentication - exchange code for token const tokenResponse = await axios.post('/api/auth/token', { code }); if (tokenResponse.data.success) { const authData = tokenResponse.data.data; appVersion.value = tokenResponse.data.version || ''; authenticated.value = true; baseUrl.value = authData.installation.url; user.value = { id: authData.user.id, firstName: authData.user.firstName, lastName: authData.user.lastName, email: authData.user.email }; apps.value = await getApps(); window.history.replaceState({}, '', '/'); } } } else { // No code - check if already authenticated const statusResponse = await axios.get('/api/auth/status'); appVersion.value = statusResponse.data.version || ''; if (statusResponse.data.authenticated) { authenticated.value = true; baseUrl.value = statusResponse.data.installation.url; user.value = { id: statusResponse.data.user.id, firstName: statusResponse.data.user.firstName, lastName: statusResponse.data.user.lastName, email: statusResponse.data.user.email }; apps.value = await getApps(); } } } catch (err) { console.error('Error:', err); error.value = err.response?.data?.error || err.message; } finally { loading.value = false; } }); return { REDIRECT_URI, authenticated, baseUrl, apps, user, loading, error, generatingToken, authenticating, showCreateAppModal, creatingApp, newApp, toasts, appVersion, canGenerateToken, isOwnApp, userApps, signIn, generateTokenForApp, createNewApp, copyToClipboard, openDeveloperPortal, showToast, logout, }; } }).mount("#app"); </script> </body> </html>