@teamwork/get-bearer-token
Version:
CLI tool to obtain bearer tokens for Teamwork API using OAuth flow
535 lines (479 loc) • 22.3 kB
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>