WIP
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
4
src/admin/src/utils/validation.ts
Normal file
4
src/admin/src/utils/validation.ts
Normal 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';
|
||||
}
|
||||
58
src/admin/src/views/AcceptInviteView.vue
Normal file
58
src/admin/src/views/AcceptInviteView.vue
Normal 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>
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user