This commit is contained in:
2026-04-18 19:45:16 -07:00
parent 7545441a3e
commit 4c6df1f037
20 changed files with 1537 additions and 125 deletions

View File

@@ -5,6 +5,9 @@ import type {
UpdateOrganizationRequest,
OrganizationMemberResponse,
InviteUserRequest,
InviteUsersByEmailRequest,
InviteByEmailResult,
InviteTokenResponse,
PaginatedResponse,
} from '@/types/api';
@@ -49,3 +52,32 @@ export async function inviteOrganizationMember(organizationId: number, request:
export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${organizationId}/users/${userId}`);
}
export async function inviteOrganizationMembersByEmail(
organizationId: number,
request: InviteUsersByEmailRequest,
): Promise<InviteByEmailResult[]> {
const { data } = await apiClient.post<InviteByEmailResult[]>(
`/v1/organisations/${organizationId}/users/invite-by-email`,
request,
);
return data;
}
export async function getInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.get<InviteTokenResponse>(
`/v1/organisations/${organizationId}/invite-link`,
);
return data;
}
export async function regenerateInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.post<InviteTokenResponse>(
`/v1/organisations/${organizationId}/invite-link/regenerate`,
);
return data;
}
export async function acceptInvite(token: string): Promise<void> {
await apiClient.post(`/v1/organisations/invite/${token}/accept`);
}

View File

@@ -75,6 +75,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: 'accept-invite/:token',
name: 'AcceptInvite',
component: () => import('@/views/AcceptInviteView.vue'),
meta: { public: true, allowAuthenticated: true },
},
{
path: 'register',
name: 'Register',
@@ -105,7 +111,7 @@ router.beforeEach((to) => {
return { name: 'Login', query: { redirect: to.fullPath } };
}
if (isPublic && isAuthenticated) {
if (isPublic && isAuthenticated && !to.matched.some((r) => r.meta.allowAuthenticated)) {
return { name: 'Dashboard' };
}

View File

@@ -88,6 +88,25 @@ export interface InviteUserRequest {
role: 'Admin' | 'User';
}
export interface InviteByEmailEntry {
email: string;
role: 'Admin' | 'User';
}
export interface InviteUsersByEmailRequest {
invites: InviteByEmailEntry[];
}
export interface InviteByEmailResult {
email: string;
success: boolean;
error: string | null;
}
export interface InviteTokenResponse {
token: string;
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface CreateApiKeyRequest {

View File

@@ -0,0 +1,4 @@
export function validateEmail(v: string): true | string {
if (!v.trim()) return 'Email required';
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) || 'Invalid email';
}

View File

@@ -0,0 +1,58 @@
<template>
<div class="d-flex align-center justify-center" style="min-height: 100vh">
<v-card rounded="lg" max-width="400" width="100%" class="ma-4">
<v-card-text class="pa-8 text-center">
<v-progress-circular v-if="state === 'loading'" indeterminate color="primary" class="mb-4" />
<template v-else-if="state === 'success'">
<v-icon color="success" size="48" class="mb-4">ri-check-line</v-icon>
<h2 class="text-h6 font-weight-bold mb-2">You're in!</h2>
<p class="text-body-2 text-medium-emphasis mb-6">You have successfully joined the organization.</p>
<v-btn color="primary" variant="flat" @click="$router.push('/dashboard')">Go to Dashboard</v-btn>
</template>
<template v-else-if="state === 'not-authenticated'">
<v-icon color="info" size="48" class="mb-4">ri-login-box-line</v-icon>
<h2 class="text-h6 font-weight-bold mb-2">Sign in to accept invite</h2>
<p class="text-body-2 text-medium-emphasis mb-6">You need to be logged in to accept this invitation.</p>
<v-btn color="primary" variant="flat" @click="$router.push({ name: 'Login', query: { redirect: $route.fullPath } })">Sign In</v-btn>
</template>
<template v-else>
<v-icon color="error" size="48" class="mb-4">ri-error-warning-line</v-icon>
<h2 class="text-h6 font-weight-bold mb-2">Invalid invite link</h2>
<p class="text-body-2 text-medium-emphasis mb-6">This invite link is invalid or has expired.</p>
<v-btn color="primary" variant="flat" @click="$router.push('/dashboard')">Go to Dashboard</v-btn>
</template>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { acceptInvite } from '@/api/organizations';
import { getAccessToken } from '@/api/client';
type State = 'loading' | 'success' | 'error' | 'not-authenticated';
const route = useRoute();
const state = ref<State>('loading');
onMounted(async () => {
const token = route.params.token as string;
if (!getAccessToken()) {
state.value = 'not-authenticated';
return;
}
try {
await acceptInvite(token);
state.value = 'success';
} catch {
state.value = 'error';
}
});
</script>

View File

@@ -116,11 +116,6 @@
<span class="text-body-2 font-weight-medium">{{ item.resourceType }}</span>
</template>
<!-- Resource ID -->
<template #item.resourceId="{ item }: { item: AuditLogResponse }">
<span class="text-body-2 text-medium-emphasis">{{ item.resourceId }}</span>
</template>
<!-- Changes (expandable) -->
<template #item.changes="{ item }: { item: AuditLogResponse }">
<div v-if="item.changes">
@@ -226,7 +221,6 @@ const headers = [
{ title: 'User', key: 'actorUserName', sortable: false },
{ title: 'Action', key: 'action', sortable: false, width: '120' },
{ title: 'Resource Type', key: 'resourceType', sortable: false, width: '160' },
{ title: 'Resource ID', key: 'resourceId', sortable: false },
{ title: 'Changes', key: 'changes', sortable: false },
];

View File

@@ -9,7 +9,7 @@
<v-card-text class="pa-6">
<div class="d-flex align-center ga-4 mb-6">
<v-avatar size="56">
<img :src="gravatarUrl" :alt="authStore.displayName" referrerpolicy="no-referrer" />
<v-img :src="gravatarUrl" :alt="authStore.displayName" referrerpolicy="no-referrer" />
</v-avatar>
<div>
<p class="text-body-1 font-weight-medium mb-0" data-testid="profile-display-name">
@@ -24,7 +24,7 @@
<v-divider class="mb-5" />
<v-form ref="profileFormRef" @submit.prevent="onSaveProfile">
<v-row dense>
<v-row dense class="mb-2">
<v-col cols="6">
<v-text-field
v-model="profileForm.firstName"
@@ -53,22 +53,23 @@
type="email"
variant="outlined"
density="compact"
:rules="[required]"
:rules="[required, validateEmail]"
:error-messages="profileError ? [profileError] : []"
class="mb-2"
data-testid="email-input"
/>
<v-btn
type="submit"
color="primary"
variant="flat"
block
:loading="isSavingProfile"
data-testid="save-profile-btn"
>
Save changes
</v-btn>
<div class="d-flex justify-end">
<v-btn
type="submit"
color="primary"
variant="flat"
:loading="isSavingProfile"
data-testid="save-profile-btn"
>
Save changes
</v-btn>
</div>
<v-alert
v-if="profileSuccess"
@@ -95,17 +96,18 @@
<!-- Sign Out Card -->
<v-card rounded="lg">
<v-card-text class="pa-6">
<v-btn
color="error"
variant="outlined"
prepend-icon="ri-logout-box-line"
block
:loading="isLoggingOut"
data-testid="logout-btn"
@click="onLogout"
>
Sign out
</v-btn>
<div class="d-flex justify-end">
<v-btn
color="error"
variant="outlined"
prepend-icon="ri-logout-box-line"
:loading="isLoggingOut"
data-testid="logout-btn"
@click="onLogout"
>
Sign out
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
@@ -156,16 +158,17 @@
@click:append-inner="showConfirm = !showConfirm"
/>
<v-btn
type="submit"
color="primary"
variant="flat"
block
:loading="isChangingPassword"
data-testid="change-password-btn"
>
Change password
</v-btn>
<div class="d-flex justify-end">
<v-btn
type="submit"
color="primary"
variant="flat"
:loading="isChangingPassword"
data-testid="change-password-btn"
>
Change password
</v-btn>
</div>
<v-alert
v-if="passwordError"
@@ -202,6 +205,7 @@ import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import OrgSelector from '@/components/nav/OrgSelector.vue';
import { getMe, updateMe, changePassword } from '@/api/users';
import { validateEmail } from '@/utils/validation';
const authStore = useAuthStore();
const router = useRouter();

View File

@@ -33,6 +33,7 @@
:items-per-page="20"
hover
data-testid="segments-table"
@click:row="(_: Event, { item }: { item: SegmentResponse }) => openEditDialog(item)"
>
<!-- Name -->
<template #item.name="{ item }: { item: SegmentResponse }">
@@ -51,21 +52,14 @@
<!-- Actions -->
<template #item.actions="{ item }: { item: SegmentResponse }">
<div class="d-flex align-center">
<v-btn
icon="ri-edit-line"
size="small"
variant="text"
:data-testid="`edit-segment-${item.id}`"
@click="openEditDialog(item)"
/>
<div class="d-flex align-center justify-end">
<v-btn
icon="ri-delete-bin-line"
size="small"
variant="text"
color="error"
:data-testid="`delete-segment-${item.id}`"
@click="openDeleteConfirm(item)"
@click.stop="openDeleteConfirm(item)"
/>
</div>
</template>
@@ -153,7 +147,7 @@ const headers = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Rules', key: 'rules', sortable: false, width: '80' },
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
{ title: '', key: 'actions', sortable: false, width: '80', align: 'end' as const },
{ title: '', key: 'actions', sortable: false, width: '52', align: 'end' as const },
];
function showError(message: string): void {

View File

@@ -54,9 +54,44 @@
<!-- Org members -->
<v-card rounded="lg" class="mb-4">
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">Members</v-card-title>
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-2">
<span class="text-body-1 font-weight-medium">Members</span>
<v-btn
size="small"
color="primary"
variant="tonal"
prepend-icon="ri-user-add-line"
data-testid="add-member-btn"
@click="openAddMemberDialog"
>
Add Member
</v-btn>
</v-card-title>
<v-card-text class="pa-4 pt-0">
<v-table v-if="orgMembers.length > 0" density="compact" class="mb-3" data-testid="members-table">
<!-- Invite link -->
<div class="d-flex align-center ga-2 mb-4">
<v-text-field
:model-value="inviteLink"
label="Invite link"
variant="outlined"
density="compact"
hide-details
readonly
:loading="isLoadingInviteLink"
data-testid="invite-link-input"
/>
<v-btn
:icon="inviteLinkCopied ? 'ri-check-line' : 'ri-file-copy-line'"
:color="inviteLinkCopied ? 'success' : undefined"
variant="text"
size="small"
:loading="isRegeneratingLink"
data-testid="copy-invite-link-btn"
@click="onCopyInviteLink"
/>
</div>
<v-table v-if="orgMembers.length > 0" density="compact" data-testid="members-table">
<thead>
<tr>
<th>First Name</th>
@@ -81,42 +116,7 @@
</tr>
</tbody>
</v-table>
<p v-else class="text-body-2 text-medium-emphasis mb-3" data-testid="no-members-message">No members found.</p>
<!-- Invite form -->
<div class="d-flex align-center ga-2 flex-wrap">
<v-text-field
v-model="inviteUserId"
label="User ID"
variant="outlined"
density="compact"
hide-details
type="number"
style="max-width: 120px"
data-testid="invite-user-id-input"
/>
<v-select
v-model="inviteRole"
:items="['Admin', 'User']"
label="Role"
variant="outlined"
density="compact"
hide-details
style="max-width: 120px"
data-testid="invite-role-select"
/>
<v-btn
color="primary"
variant="tonal"
size="small"
:disabled="!inviteUserId"
:loading="isInviting"
data-testid="invite-member-btn"
@click="onInviteMember"
>
Invite
</v-btn>
</div>
<p v-else class="text-body-2 text-medium-emphasis" data-testid="no-members-message">No members found.</p>
</v-card-text>
</v-card>
@@ -514,6 +514,88 @@
</v-card>
</v-dialog>
<!-- Add Member dialog -->
<v-dialog v-model="showAddMemberDialog" max-width="520" persistent>
<v-card rounded="lg" data-testid="add-member-dialog">
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Add Members</v-card-title>
<v-card-text class="pa-4 pt-0">
<div
v-for="(entry, i) in addMemberEntries"
:key="i"
class="d-flex align-start ga-2 mb-2"
>
<v-text-field
v-model="entry.email"
label="Email"
variant="outlined"
density="compact"
:rules="[validateEmail]"
:data-testid="`add-member-email-${i}`"
/>
<v-select
v-model="entry.role"
:items="['User', 'Admin']"
label="Role"
variant="outlined"
density="compact"
hide-details
style="max-width: 120px"
:data-testid="`add-member-role-${i}`"
/>
<v-btn
v-if="addMemberEntries.length > 1"
icon="ri-close-line"
size="x-small"
variant="text"
@click="removeMemberRow(i)"
/>
</div>
<v-btn
size="small"
variant="text"
prepend-icon="ri-add-line"
class="mt-1"
data-testid="add-another-member-btn"
@click="addMemberRow"
>
Add another
</v-btn>
<div v-if="addMemberResults.length > 0" class="mt-3">
<div
v-for="result in addMemberResults"
:key="result.email"
class="d-flex align-center ga-2 text-body-2 mb-1"
>
<v-icon :color="result.success ? 'success' : 'error'" size="16">
{{ result.success ? 'ri-check-line' : 'ri-error-warning-line' }}
</v-icon>
<span>{{ result.email }}</span>
<span v-if="!result.success" class="text-error"> {{ result.error }}</span>
</div>
</div>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer />
<v-btn variant="text" :disabled="isAddingMembers" @click="showAddMemberDialog = false">
{{ addMemberResults.length > 0 ? 'Close' : 'Cancel' }}
</v-btn>
<v-btn
v-if="addMemberResults.length === 0"
color="primary"
variant="flat"
:loading="isAddingMembers"
:disabled="!addMemberEntries.some(e => e.email.trim()) || addMemberEntries.some(e => e.email.trim() && validateEmail(e.email) !== true)"
data-testid="confirm-add-members-btn"
@click="onAddMembers"
>
Add
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Raw key reveal dialog (shown once after creation) -->
<v-dialog v-model="showRawKeyDialog" max-width="480" persistent>
<v-card rounded="lg" data-testid="raw-key-dialog">
@@ -546,13 +628,14 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import { updateOrganization, listOrganizationMembers, inviteOrganizationMember, removeOrganizationMember } from '@/api/organizations';
import { updateOrganization, listOrganizationMembers, removeOrganizationMember, inviteOrganizationMembersByEmail, getInviteLink, regenerateInviteLink } from '@/api/organizations';
import { updateProject, listProjectUserPermissions, setProjectUserPermissions, updateProjectUserPermissions, removeProjectUserPermissions } from '@/api/projects';
import { listEnvironments, createEnvironment, updateEnvironment, deleteEnvironment, cloneEnvironment } from '@/api/environments';
import { listApiKeys, createApiKey, deleteApiKey } from '@/api/apiKeys';
import { useContextStore } from '@/stores/context';
import WebhooksPanel from '@/components/settings/WebhooksPanel.vue';
import type { OrganizationMemberResponse, UserPermissionResponse, EnvironmentResponse, ApiKeyResponse, ProjectPermission } from '@/types/api';
import type { OrganizationMemberResponse, UserPermissionResponse, EnvironmentResponse, ApiKeyResponse, ProjectPermission, InviteByEmailResult } from '@/types/api';
import { validateEmail } from '@/utils/validation';
const contextStore = useContextStore();
@@ -595,13 +678,28 @@ const isSavingOrg = ref(false);
const orgError = ref<string | null>(null);
const orgSuccess = ref<string | null>(null);
const orgMembers = ref<OrganizationMemberResponse[]>([]);
const inviteUserId = ref('');
const inviteRole = ref<'Admin' | 'User'>('User');
const isInviting = ref(false);
// Invite link
const inviteLink = ref('');
const isLoadingInviteLink = ref(false);
const isRegeneratingLink = ref(false);
const inviteLinkCopied = ref(false);
// Add member dialog
interface AddMemberEntry { email: string; role: 'Admin' | 'User' }
const showAddMemberDialog = ref(false);
const addMemberEntries = ref<AddMemberEntry[]>([{ email: '', role: 'User' }]);
const isAddingMembers = ref(false);
const addMemberResults = ref<InviteByEmailResult[]>([]);
watch(() => contextStore.currentOrganization, (org) => {
orgNameInput.value = org?.name ?? '';
if (org) loadOrgMembers();
if (org) {
loadOrgMembers();
loadInviteLink();
} else {
inviteLink.value = '';
}
}, { immediate: true });
async function loadOrgMembers(): Promise<void> {
@@ -630,19 +728,69 @@ async function onSaveOrgName(): Promise<void> {
}
}
async function onInviteMember(): Promise<void> {
async function loadInviteLink(): Promise<void> {
const orgId = contextStore.currentOrganization?.id;
if (!orgId || !inviteUserId.value) return;
isInviting.value = true;
orgError.value = null;
if (!orgId) return;
isLoadingInviteLink.value = true;
try {
await inviteOrganizationMember(orgId, { userId: Number(inviteUserId.value), role: inviteRole.value });
inviteUserId.value = '';
const { token } = await getInviteLink(orgId);
inviteLink.value = `${window.location.origin}/accept-invite/${token}`;
} catch {
// non-critical
} finally {
isLoadingInviteLink.value = false;
}
}
async function onCopyInviteLink(): Promise<void> {
const orgId = contextStore.currentOrganization?.id;
if (!orgId) return;
try {
await navigator.clipboard.writeText(inviteLink.value);
inviteLinkCopied.value = true;
setTimeout(() => { inviteLinkCopied.value = false; }, 2000);
} catch {
// clipboard unavailable
}
isRegeneratingLink.value = true;
try {
const { token } = await regenerateInviteLink(orgId);
inviteLink.value = `${window.location.origin}/accept-invite/${token}`;
} catch {
// non-critical
} finally {
isRegeneratingLink.value = false;
}
}
function openAddMemberDialog(): void {
addMemberEntries.value = [{ email: '', role: 'User' }];
addMemberResults.value = [];
showAddMemberDialog.value = true;
}
function addMemberRow(): void {
addMemberEntries.value.push({ email: '', role: 'User' });
}
function removeMemberRow(index: number): void {
addMemberEntries.value.splice(index, 1);
}
async function onAddMembers(): Promise<void> {
const orgId = contextStore.currentOrganization?.id;
if (!orgId) return;
const valid = addMemberEntries.value.filter((e) => e.email.trim());
if (!valid.length) return;
isAddingMembers.value = true;
addMemberResults.value = [];
try {
addMemberResults.value = await inviteOrganizationMembersByEmail(orgId, { invites: valid });
await loadOrgMembers();
} catch {
orgError.value = 'Failed to invite member.';
orgError.value = 'Failed to add members.';
} finally {
isInviting.value = false;
isAddingMembers.value = false;
}
}
@@ -976,6 +1124,7 @@ onMounted(() => {
orgNameInput.value = contextStore.currentOrganization.name;
loadOrgMembers();
loadApiKeys();
loadInviteLink();
}
if (contextStore.currentProject) {
projectNameInput.value = contextStore.currentProject.name;