Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default {
account: 'account',
tasks: 'tasks',
billing: 'billing',
developers: 'developers',
},
},
admin: {
Expand Down
1 change: 1 addition & 0 deletions src/config/defaults/test.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export default {
users: 'users',
tasks: 'tasks',
billing: 'billing',
developers: 'developers',
},
},
cookie: { prefix: 'devkit' },
Expand Down
3 changes: 2 additions & 1 deletion src/modules/app/app.router.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import admin from '../admin/router/admin.router';
import users from '../users/router/users.router';
import tasks from '../tasks/router/tasks.router';
import billing from '../billing/router/billing.router';
import developers from '../developers/router/developers.router';

const routes = [].concat(home, auth, organizations, admin, users, tasks, billing);
const routes = [].concat(home, auth, organizations, admin, users, tasks, billing, developers);
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Resolved
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Resolved

/**
* Router configuration.
Expand Down
149 changes: 149 additions & 0 deletions src/modules/developers/components/developers.keyCreate.component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<template>
<v-dialog :model-value="modelValue" max-width="440" @update:model-value="$emit('update:modelValue', $event)">
<v-card :class="config.vuetify.theme.rounded" class="pa-6">
<v-card-title class="text-title-large font-weight-medium pa-0 mb-4">
Create API Key
</v-card-title>
<v-card-text class="pa-0">
<v-form ref="form" @submit.prevent="submit">
<v-text-field
v-model="name"
label="Name"
variant="outlined"
density="compact"
:rules="[rules.required]"
class="mb-3"
/>
<v-select
v-model="scopes"
:items="scopeOptions"
label="Scopes"
variant="outlined"
density="compact"
multiple
chips
closable-chips
class="mb-3"
/>
<v-text-field
v-model="expiresAt"
label="Expiry (optional)"
type="date"
variant="outlined"
density="compact"
:min="minDate"
/>
</v-form>
</v-card-text>
<v-card-actions class="pa-0 mt-4">
<v-spacer />
<v-btn
variant="outlined"
:class="config.vuetify.theme.rounded"
class="text-none text-body-medium"
@click="close"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="flat"
:class="config.vuetify.theme.rounded"
class="text-none text-body-medium"
:loading="submitting"
@click="submit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script>
import { useDevelopersStore } from '../stores/developers.store';
import config from '../../../lib/services/config';

export default {
name: 'DevelopersKeyCreateComponent',
props: {
modelValue: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'created'],
data() {
return {
config,
name: '',
scopes: ['read'],
expiresAt: '',
submitting: false,
scopeOptions: ['read', 'write'],
rules: {
required: (v) => !!v || 'Required',
},
};
},
computed: {
/**
* @desc Minimum date for the expiry picker (tomorrow).
* @returns {string} ISO date string (YYYY-MM-DD)
*/
minDate() {
const d = new Date();
d.setDate(d.getDate() + 1);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
},
methods: {
/**
* @desc Submit the create key form.
* @returns {Promise<void>}
*/
async submit() {
const { valid } = await this.$refs.form.validate();
if (!valid) return;

this.submitting = true;
try {
const store = useDevelopersStore();
const body = {
name: this.name,
scopes: this.scopes,
};
if (this.expiresAt) {
const [year, month, day] = this.expiresAt.split('-').map(Number);
body.expiresAt = new Date(Date.UTC(year, month - 1, day)).toISOString();
}
const key = await store.createKey(body);
this.$emit('created', key);
this.reset();
} catch {
/* interceptor handles */
} finally {
this.submitting = false;
}
},

/**
* @desc Close the dialog and reset form state.
*/
close() {
this.$emit('update:modelValue', false);
this.reset();
},

/**
* @desc Reset form fields to defaults.
*/
reset() {
this.name = '';
this.scopes = ['read'];
this.expiresAt = '';
if (this.$refs.form) this.$refs.form.resetValidation();
},
},
};
</script>
213 changes: 213 additions & 0 deletions src/modules/developers/components/developers.keysTab.component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<template>
<div>
<div class="d-flex align-center justify-space-between mb-4">
<span class="text-title-medium font-weight-medium">Your API Keys</span>
<v-btn
color="primary"
variant="flat"
:class="config.vuetify.theme.rounded"
class="text-none text-body-medium"
@click="$emit('create')"
>
Create Key
</v-btn>
</div>

<!-- Empty state -->
<v-card v-if="!loading && keys.length === 0" :class="config.vuetify.theme.rounded" class="pa-6 text-center">
<v-icon icon="fa-solid fa-key" size="x-large" color="primary" class="mb-4 text-medium-emphasis" />
<h2 class="text-title-large font-weight-medium mb-2">No API keys yet</h2>
<p class="text-body-medium text-medium-emphasis">
Create one to get started.
</p>
</v-card>

<!-- Data table -->
<v-card v-else :class="config.vuetify.theme.rounded">
<v-data-table-server
:headers="headers"
:items="keys"
:items-length="keysTotal"
:loading="loading"
:items-per-page="20"
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Resolved
:items-per-page-options="[10, 20, 50]"
item-value="_id"
@update:options="onOptions"
>
<template #[`item.prefix`]="{ item }">
<code class="text-body-small">{{ item.prefix }}...</code>
</template>

<template #[`item.scopes`]="{ item }">
<v-chip
v-for="scope in (item.scopes || [])"
:key="scope"
size="small"
variant="tonal"
class="mr-1 text-capitalize"
>
{{ scope }}
</v-chip>
<span v-if="!item.scopes || !item.scopes.length" class="text-body-small text-medium-emphasis">
None
</span>
</template>

<template #[`item.createdAt`]="{ item }">
<span class="text-body-small">{{ formatDate(item.createdAt) }}</span>
</template>

<template #[`item.lastUsedAt`]="{ item }">
<span class="text-body-small">{{ item.lastUsedAt ? formatRelative(item.lastUsedAt) : 'Never' }}</span>
</template>

<template #[`item.actions`]="{ item }">
<v-btn
color="error"
variant="tonal"
size="small"
:class="config.vuetify.theme.rounded"
class="text-none text-body-small"
@click="confirmRevoke(item)"
>
Revoke
</v-btn>
</template>
</v-data-table-server>
</v-card>

<!-- Revoke confirmation dialog -->
<v-dialog v-model="showRevokeDialog" max-width="440">
<v-card :class="config.vuetify.theme.rounded" class="pa-6">
<v-card-title class="text-title-large font-weight-medium pa-0 mb-2">
Revoke API Key
</v-card-title>
<v-card-text class="pa-0 mb-4 text-body-medium">
Are you sure you want to revoke <strong>{{ revokeTarget?.name }}</strong>?
This action cannot be undone.
</v-card-text>
<v-card-actions class="pa-0">
<v-spacer />
<v-btn
variant="outlined"
:class="config.vuetify.theme.rounded"
class="text-none text-body-medium"
@click="showRevokeDialog = false"
>
Cancel
</v-btn>
<v-btn
color="error"
variant="flat"
:class="config.vuetify.theme.rounded"
class="text-none text-body-medium"
@click="executeRevoke"
>
Revoke
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>

<script>
import { useDisplay } from 'vuetify';
import config from '../../../lib/services/config';

export default {
name: 'DevelopersKeysTab',
props: {
keys: { type: Array, default: () => [] },
keysTotal: { type: Number, default: 0 },
loading: { type: Boolean, default: false },
},
emits: ['create', 'revoke', 'update:options'],
data() {
return {
config,
showRevokeDialog: false,
revokeTarget: null,
};
},
computed: {
headers() {
const { smAndDown } = useDisplay();
const base = [
{ title: 'Name', key: 'name', sortable: false },
{ title: 'Prefix', key: 'prefix', sortable: false },
{ title: 'Scopes', key: 'scopes', sortable: false },
];
if (!smAndDown.value) {
base.push(
{ title: 'Created', key: 'createdAt', sortable: false },
{ title: 'Last Used', key: 'lastUsedAt', sortable: false },
);
}
base.push({ title: '', key: 'actions', sortable: false, align: 'end' });
return base;
},
},
methods: {
/**
* @desc Format a date string for display.
* @param {string} date - ISO date string
* @returns {string} Formatted date
*/
formatDate(date) {
if (!date) return '';
return new Date(date).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
},

/**
* @desc Format a date as a relative time string.
* @param {string} date - ISO date string
* @returns {string} Relative time (e.g. "2d ago")
*/
formatRelative(date) {
if (!date) return '';
const diff = Date.now() - new Date(date).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return this.formatDate(date);
},

/**
* @desc Open the revoke confirmation dialog.
* @param {Object} item - The key to revoke
*/
confirmRevoke(item) {
this.revokeTarget = item;
this.showRevokeDialog = true;
},

/**
* @desc Execute the revoke action and close dialog.
*/
executeRevoke() {
if (this.revokeTarget) {
this.$emit('revoke', this.revokeTarget.id || this.revokeTarget._id);
}
this.showRevokeDialog = false;
this.revokeTarget = null;
},

/**
* @desc Forward table options changes to parent.
* @param {Object} options - Table options ({ page, itemsPerPage })
*/
onOptions(options) {
this.$emit('update:options', options);
},
},
};
</script>
Loading
Loading