WIP
This commit is contained in:
42
CLAUDE.md
42
CLAUDE.md
@@ -1,44 +1,42 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code (claude.ai/code) when working in this repo.
|
||||
|
||||
## Project
|
||||
|
||||
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
|
||||
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
|
||||
|
||||
## Planned Structure
|
||||
|
||||
- `src/admin/` — administrative components in VueJS
|
||||
- `src/api/` - api and restful endpoints in .NET
|
||||
- `src/admin/` — VueJS admin components
|
||||
- `src/api/` - .NET API + REST endpoints
|
||||
- `tests/` — test suite
|
||||
- `docs/` — documentation
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version
|
||||
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled
|
||||
- Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it)
|
||||
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
|
||||
- Any file modified should be evaluated for potential test cases and missing coverage areas.
|
||||
- Use latest LTS .NET + latest supported nuget packages for that version
|
||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
||||
- Organize code by feature/area, not type (e.g. `features` namespace)
|
||||
- New features need unit tests covering as much logic as possible
|
||||
- Any modified file: evaluate for missing test coverage
|
||||
|
||||
# Coding
|
||||
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
|
||||
- Coding should match formating and style rules in the .editorconfig file
|
||||
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
||||
- Match formatting/style from `.editorconfig`
|
||||
|
||||
## Testing
|
||||
- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears)
|
||||
- Mock any external dependencies using Moq.
|
||||
- Do not name any mocked objects with the word Mock in them
|
||||
- Do not include any Arrange / Act / Assert comments in the code
|
||||
|
||||
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
|
||||
- Mock external deps with Moq
|
||||
- No "Mock" in mocked object names
|
||||
- No Arrange/Act/Assert comments
|
||||
|
||||
## Claude
|
||||
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
|
||||
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently.
|
||||
- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end
|
||||
- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end
|
||||
|
||||
- Plans = `.md` files in `docs/`. Admin → `docs/admin/`, API → `docs/api/`
|
||||
- Split large plans into discrete chunks — each buildable + committable independently
|
||||
- Plan generated from `docs/<name>.md` → save as `docs/<name>_plan.md`
|
||||
- Plan implemented from `docs/<name>.md` → save summary as `docs/<name>_output.md`
|
||||
|
||||
## Stack
|
||||
|
||||
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.
|
||||
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
|
||||
44
CLAUDE.original.md
Normal file
44
CLAUDE.original.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
|
||||
|
||||
## Planned Structure
|
||||
|
||||
- `src/admin/` — administrative components in VueJS
|
||||
- `src/api/` - api and restful endpoints in .NET
|
||||
- `tests/` — test suite
|
||||
- `docs/` — documentation
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version
|
||||
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled
|
||||
- Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it)
|
||||
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
|
||||
- Any file modified should be evaluated for potential test cases and missing coverage areas.
|
||||
|
||||
# Coding
|
||||
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
|
||||
- Coding should match formating and style rules in the .editorconfig file
|
||||
|
||||
## Testing
|
||||
- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears)
|
||||
- Mock any external dependencies using Moq.
|
||||
- Do not name any mocked objects with the word Mock in them
|
||||
- Do not include any Arrange / Act / Assert comments in the code
|
||||
|
||||
|
||||
## Claude
|
||||
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
|
||||
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently.
|
||||
- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end
|
||||
- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end
|
||||
|
||||
|
||||
## Stack
|
||||
|
||||
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<div class="d-flex justify-end">
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
block
|
||||
: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">
|
||||
<div class="d-flex justify-end">
|
||||
<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>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -156,16 +158,17 @@
|
||||
@click:append-inner="showConfirm = !showConfirm"
|
||||
/>
|
||||
|
||||
<div class="d-flex justify-end">
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
block
|
||||
: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;
|
||||
|
||||
@@ -11,6 +11,8 @@ public class OrganizationConfiguration : IEntityTypeConfiguration<Organization>
|
||||
builder.HasKey(o => o.Id);
|
||||
builder.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
||||
builder.Property(o => o.CreatedAt).IsRequired();
|
||||
builder.Property(o => o.InviteToken).HasMaxLength(64);
|
||||
builder.HasIndex(o => o.InviteToken).IsUnique().HasFilter("\"InviteToken\" IS NOT NULL");
|
||||
|
||||
builder.HasMany(o => o.Members)
|
||||
.WithOne()
|
||||
|
||||
936
src/api/MicCheck.Api/Migrations/20260417015326_AddOrganizationInviteToken.Designer.cs
generated
Normal file
936
src/api/MicCheck.Api/Migrations/20260417015326_AddOrganizationInviteToken.Designer.cs
generated
Normal file
@@ -0,0 +1,936 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MicCheck.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(MicCheckDbContext))]
|
||||
[Migration("20260417015326_AddOrganizationInviteToken")]
|
||||
partial class AddOrganizationInviteToken
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FeatureTags", b =>
|
||||
{
|
||||
b.Property<int>("FeatureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TagsId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("FeatureId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("FeatureTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int?>("ActorUserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Changes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ResourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId", "CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Authorization.UserProjectPermission", b =>
|
||||
{
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Permissions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "ProjectId");
|
||||
|
||||
b.ToTable("UserProjectPermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApiKey")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Environments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DefaultEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<string>("InitialValue")
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Features");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FeatureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SegmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("FeatureSegments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FeatureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("FeatureSegmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("IdentityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnvironmentId");
|
||||
|
||||
b.HasIndex("FeatureSegmentId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IdentityId");
|
||||
|
||||
b.HasIndex("FeatureId", "EnvironmentId", "IdentityId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Identifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnvironmentId", "Identifier")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Identities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("IdentityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("ValueType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdentityId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("IdentityTraits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InviteToken")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InviteToken")
|
||||
.IsUnique()
|
||||
.HasFilter("\"InviteToken\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Organizations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
|
||||
{
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("OrganizationId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("OrganizationUsers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("HideDisabledFlags")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Segments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Operator")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Property")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("RuleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RuleId");
|
||||
|
||||
b.ToTable("SegmentConditions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("ParentRuleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SegmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentRuleId");
|
||||
|
||||
b.HasIndex("SegmentId");
|
||||
|
||||
b.ToTable("SegmentRules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnvironmentId");
|
||||
|
||||
b.ToTable("Webhooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AttemptNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("AttemptedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<TimeSpan>("Duration")
|
||||
.HasColumnType("interval");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ResponseBody")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("ResponseStatusCode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("WebhookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WebhookId");
|
||||
|
||||
b.ToTable("WebhookDeliveryLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FeatureTags", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FeatureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Features.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||
.WithMany("ApiKeys")
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany("Environments")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany("Features")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Environments.Environment", null)
|
||||
.WithMany("FeatureStates")
|
||||
.HasForeignKey("EnvironmentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||
.WithMany("FeatureStates")
|
||||
.HasForeignKey("FeatureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Features.FeatureSegment", null)
|
||||
.WithOne("FeatureState")
|
||||
.HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MicCheck.Api.Identities.Identity", null)
|
||||
.WithMany("FeatureStateOverrides")
|
||||
.HasForeignKey("IdentityId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Identities.Identity", null)
|
||||
.WithMany("Traits")
|
||||
.HasForeignKey("IdentityId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Users.User", "User")
|
||||
.WithMany("Organizations")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany("Segments")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
|
||||
.WithMany("Conditions")
|
||||
.HasForeignKey("RuleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
|
||||
.WithMany("ChildRules")
|
||||
.HasForeignKey("ParentRuleId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.HasOne("MicCheck.Api.Segments.Segment", null)
|
||||
.WithMany("Rules")
|
||||
.HasForeignKey("SegmentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Users.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||
{
|
||||
b.Navigation("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||
{
|
||||
b.Navigation("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
|
||||
{
|
||||
b.Navigation("FeatureState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
|
||||
{
|
||||
b.Navigation("FeatureStateOverrides");
|
||||
|
||||
b.Navigation("Traits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
|
||||
{
|
||||
b.Navigation("ApiKeys");
|
||||
|
||||
b.Navigation("Members");
|
||||
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Navigation("Environments");
|
||||
|
||||
b.Navigation("Features");
|
||||
|
||||
b.Navigation("Segments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||
{
|
||||
b.Navigation("Rules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||
{
|
||||
b.Navigation("ChildRules");
|
||||
|
||||
b.Navigation("Conditions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
|
||||
{
|
||||
b.Navigation("Organizations");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MicCheck.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOrganizationInviteToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InviteToken",
|
||||
table: "Organizations",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Organizations_InviteToken",
|
||||
table: "Organizations",
|
||||
column: "InviteToken",
|
||||
unique: true,
|
||||
filter: "\"InviteToken\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Organizations_InviteToken",
|
||||
table: "Organizations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InviteToken",
|
||||
table: "Organizations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,10 @@ namespace MicCheck.Api.Migrations
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InviteToken")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -407,6 +411,10 @@ namespace MicCheck.Api.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InviteToken")
|
||||
.IsUnique()
|
||||
.HasFilter("\"InviteToken\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Organizations");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
public record InviteTokenResponse(string Token);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
public record InviteUsersByEmailRequest(IReadOnlyList<InviteByEmailEntry> Invites);
|
||||
|
||||
public record InviteByEmailEntry(string Email, string Role);
|
||||
|
||||
public record InviteByEmailResult(string Email, bool Success, string? Error);
|
||||
@@ -11,4 +11,5 @@ public class Organization
|
||||
public ICollection<Project> Projects { get; init; } = [];
|
||||
public ICollection<OrganizationUser> Members { get; init; } = [];
|
||||
public ICollection<ApiKey> ApiKeys { get; init; } = [];
|
||||
public string? InviteToken { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
@@ -100,4 +101,68 @@ public class OrganizationService(MicCheckDbContext db, AuditService auditService
|
||||
db.OrganizationUsers.Remove(member);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<string> GetOrCreateInviteTokenAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId, ct)
|
||||
?? throw new KeyNotFoundException($"Organization {organizationId} not found.");
|
||||
|
||||
if (org.InviteToken is null)
|
||||
{
|
||||
org.InviteToken = Guid.NewGuid().ToString("N");
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return org.InviteToken;
|
||||
}
|
||||
|
||||
public async Task<string> RegenerateInviteTokenAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId, ct)
|
||||
?? throw new KeyNotFoundException($"Organization {organizationId} not found.");
|
||||
|
||||
org.InviteToken = Guid.NewGuid().ToString("N");
|
||||
await db.SaveChangesAsync(ct);
|
||||
return org.InviteToken;
|
||||
}
|
||||
|
||||
public async Task<Organization?> FindByInviteTokenAsync(string token, CancellationToken ct = default) =>
|
||||
await db.Organizations.FirstOrDefaultAsync(o => o.InviteToken == token, ct);
|
||||
|
||||
public async Task<IReadOnlyList<InviteByEmailResult>> InviteUsersByEmailAsync(
|
||||
int organizationId, IReadOnlyList<InviteByEmailEntry> invites, CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<InviteByEmailResult>();
|
||||
|
||||
foreach (var invite in invites)
|
||||
{
|
||||
var user = await db.Users
|
||||
.FirstOrDefaultAsync(u => u.Email == invite.Email.Trim().ToLower() && u.IsActive, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
results.Add(new InviteByEmailResult(invite.Email, false, "No user found with that email address."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<OrganizationRole>(invite.Role, ignoreCase: true, out var role))
|
||||
{
|
||||
results.Add(new InviteByEmailResult(invite.Email, false, $"Invalid role '{invite.Role}'."));
|
||||
continue;
|
||||
}
|
||||
|
||||
await InviteUserAsync(organizationId, user.Id, role, ct);
|
||||
results.Add(new InviteByEmailResult(invite.Email, true, null));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task AcceptInviteAsync(string token, int userId, CancellationToken ct = default)
|
||||
{
|
||||
var org = await FindByInviteTokenAsync(token, ct)
|
||||
?? throw new KeyNotFoundException("Invalid or expired invite link.");
|
||||
|
||||
await InviteUserAsync(org.Id, userId, OrganizationRole.User, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,54 @@ public class OrganizationsController(OrganizationService organizationService, We
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("{id}/users/invite-by-email")]
|
||||
public async Task<ActionResult<IReadOnlyList<InviteByEmailResult>>> InviteUsersByEmail(
|
||||
int id, InviteUsersByEmailRequest request, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var results = await organizationService.InviteUsersByEmailAsync(id, request.Invites, ct);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/invite-link")]
|
||||
public async Task<ActionResult<InviteTokenResponse>> GetInviteLink(int id, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var token = await organizationService.GetOrCreateInviteTokenAsync(id, ct);
|
||||
return Ok(new InviteTokenResponse(token));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/invite-link/regenerate")]
|
||||
public async Task<ActionResult<InviteTokenResponse>> RegenerateInviteLink(int id, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var token = await organizationService.RegenerateInviteTokenAsync(id, ct);
|
||||
return Ok(new InviteTokenResponse(token));
|
||||
}
|
||||
|
||||
[HttpPost("invite/{token}/accept")]
|
||||
public async Task<IActionResult> AcceptInvite(string token, CancellationToken ct)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId is null) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await organizationService.AcceptInviteAsync(token, userId.Value, ct);
|
||||
return Ok();
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound("Invalid or expired invite link.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/users/{userId}")]
|
||||
public async Task<IActionResult> RemoveUser(int id, int userId, CancellationToken ct)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user