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

@@ -1,44 +1,42 @@
# CLAUDE.md # 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 ## 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 ## Planned Structure
- `src/admin/`administrative components in VueJS - `src/admin/`VueJS admin components
- `src/api/` - api and restful endpoints in .NET - `src/api/` - .NET API + REST endpoints
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
## Best Practices ## 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 - Use latest LTS .NET + latest supported nuget packages for that version
- Ensure that langVersion is set to latest in all csproj files and nullable is enabled - Set `langVersion` to latest in all csproj files; enable nullable
- 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) - Organize code by feature/area, not type (e.g. `features` namespace)
- All new feature requests should include corresponding unit tests that cover as much of the logic as possible. - New features need unit tests covering as much logic as possible
- Any file modified should be evaluated for potential test cases and missing coverage areas. - Any modified file: evaluate for missing test coverage
# Coding # Coding
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper - Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Coding should match formating and style rules in the .editorconfig file - Match formatting/style from `.editorconfig`
## Testing ## 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) - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock any external dependencies using Moq. - Mock external deps with Moq
- Do not name any mocked objects with the word Mock in them - No "Mock" in mocked object names
- Do not include any Arrange / Act / Assert comments in the code - No Arrange/Act/Assert comments
## Claude ## Claude
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/` - Plans = `.md` files in `docs/`. Admin `docs/admin/`, API `docs/api/`
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently. - Split large plans into discrete chunks — each buildable + committable 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 - Plan generated from `docs/<name>.md` save as `docs/<name>_plan.md`
- 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 - Plan implemented from `docs/<name>.md` save summary as `docs/<name>_output.md`
## Stack ## 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
View 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.

View File

@@ -5,6 +5,9 @@ import type {
UpdateOrganizationRequest, UpdateOrganizationRequest,
OrganizationMemberResponse, OrganizationMemberResponse,
InviteUserRequest, InviteUserRequest,
InviteUsersByEmailRequest,
InviteByEmailResult,
InviteTokenResponse,
PaginatedResponse, PaginatedResponse,
} from '@/types/api'; } from '@/types/api';
@@ -49,3 +52,32 @@ export async function inviteOrganizationMember(organizationId: number, request:
export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> { export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${organizationId}/users/${userId}`); 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'), component: () => import('@/views/LoginView.vue'),
meta: { public: true }, meta: { public: true },
}, },
{
path: 'accept-invite/:token',
name: 'AcceptInvite',
component: () => import('@/views/AcceptInviteView.vue'),
meta: { public: true, allowAuthenticated: true },
},
{ {
path: 'register', path: 'register',
name: 'Register', name: 'Register',
@@ -105,7 +111,7 @@ router.beforeEach((to) => {
return { name: 'Login', query: { redirect: to.fullPath } }; return { name: 'Login', query: { redirect: to.fullPath } };
} }
if (isPublic && isAuthenticated) { if (isPublic && isAuthenticated && !to.matched.some((r) => r.meta.allowAuthenticated)) {
return { name: 'Dashboard' }; return { name: 'Dashboard' };
} }

View File

@@ -88,6 +88,25 @@ export interface InviteUserRequest {
role: 'Admin' | 'User'; 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 ───────────────────────────────────────────────────────────────── // ─── API Keys ─────────────────────────────────────────────────────────────────
export interface CreateApiKeyRequest { 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> <span class="text-body-2 font-weight-medium">{{ item.resourceType }}</span>
</template> </template>
<!-- Resource ID -->
<template #item.resourceId="{ item }: { item: AuditLogResponse }">
<span class="text-body-2 text-medium-emphasis">{{ item.resourceId }}</span>
</template>
<!-- Changes (expandable) --> <!-- Changes (expandable) -->
<template #item.changes="{ item }: { item: AuditLogResponse }"> <template #item.changes="{ item }: { item: AuditLogResponse }">
<div v-if="item.changes"> <div v-if="item.changes">
@@ -226,7 +221,6 @@ const headers = [
{ title: 'User', key: 'actorUserName', sortable: false }, { title: 'User', key: 'actorUserName', sortable: false },
{ title: 'Action', key: 'action', sortable: false, width: '120' }, { title: 'Action', key: 'action', sortable: false, width: '120' },
{ title: 'Resource Type', key: 'resourceType', sortable: false, width: '160' }, { title: 'Resource Type', key: 'resourceType', sortable: false, width: '160' },
{ title: 'Resource ID', key: 'resourceId', sortable: false },
{ title: 'Changes', key: 'changes', sortable: false }, { title: 'Changes', key: 'changes', sortable: false },
]; ];

View File

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

View File

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

View File

@@ -54,9 +54,44 @@
<!-- Org members --> <!-- Org members -->
<v-card rounded="lg" class="mb-4"> <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-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> <thead>
<tr> <tr>
<th>First Name</th> <th>First Name</th>
@@ -81,42 +116,7 @@
</tr> </tr>
</tbody> </tbody>
</v-table> </v-table>
<p v-else class="text-body-2 text-medium-emphasis mb-3" data-testid="no-members-message">No members found.</p> <p v-else class="text-body-2 text-medium-emphasis" 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>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -514,6 +514,88 @@
</v-card> </v-card>
</v-dialog> </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) --> <!-- Raw key reveal dialog (shown once after creation) -->
<v-dialog v-model="showRawKeyDialog" max-width="480" persistent> <v-dialog v-model="showRawKeyDialog" max-width="480" persistent>
<v-card rounded="lg" data-testid="raw-key-dialog"> <v-card rounded="lg" data-testid="raw-key-dialog">
@@ -546,13 +628,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from 'vue'; 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 { updateProject, listProjectUserPermissions, setProjectUserPermissions, updateProjectUserPermissions, removeProjectUserPermissions } from '@/api/projects';
import { listEnvironments, createEnvironment, updateEnvironment, deleteEnvironment, cloneEnvironment } from '@/api/environments'; import { listEnvironments, createEnvironment, updateEnvironment, deleteEnvironment, cloneEnvironment } from '@/api/environments';
import { listApiKeys, createApiKey, deleteApiKey } from '@/api/apiKeys'; import { listApiKeys, createApiKey, deleteApiKey } from '@/api/apiKeys';
import { useContextStore } from '@/stores/context'; import { useContextStore } from '@/stores/context';
import WebhooksPanel from '@/components/settings/WebhooksPanel.vue'; 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(); const contextStore = useContextStore();
@@ -595,13 +678,28 @@ const isSavingOrg = ref(false);
const orgError = ref<string | null>(null); const orgError = ref<string | null>(null);
const orgSuccess = ref<string | null>(null); const orgSuccess = ref<string | null>(null);
const orgMembers = ref<OrganizationMemberResponse[]>([]); const orgMembers = ref<OrganizationMemberResponse[]>([]);
const inviteUserId = ref('');
const inviteRole = ref<'Admin' | 'User'>('User'); // Invite link
const isInviting = ref(false); 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) => { watch(() => contextStore.currentOrganization, (org) => {
orgNameInput.value = org?.name ?? ''; orgNameInput.value = org?.name ?? '';
if (org) loadOrgMembers(); if (org) {
loadOrgMembers();
loadInviteLink();
} else {
inviteLink.value = '';
}
}, { immediate: true }); }, { immediate: true });
async function loadOrgMembers(): Promise<void> { 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; const orgId = contextStore.currentOrganization?.id;
if (!orgId || !inviteUserId.value) return; if (!orgId) return;
isInviting.value = true; isLoadingInviteLink.value = true;
orgError.value = null;
try { try {
await inviteOrganizationMember(orgId, { userId: Number(inviteUserId.value), role: inviteRole.value }); const { token } = await getInviteLink(orgId);
inviteUserId.value = ''; 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(); await loadOrgMembers();
} catch { } catch {
orgError.value = 'Failed to invite member.'; orgError.value = 'Failed to add members.';
} finally { } finally {
isInviting.value = false; isAddingMembers.value = false;
} }
} }
@@ -976,6 +1124,7 @@ onMounted(() => {
orgNameInput.value = contextStore.currentOrganization.name; orgNameInput.value = contextStore.currentOrganization.name;
loadOrgMembers(); loadOrgMembers();
loadApiKeys(); loadApiKeys();
loadInviteLink();
} }
if (contextStore.currentProject) { if (contextStore.currentProject) {
projectNameInput.value = contextStore.currentProject.name; projectNameInput.value = contextStore.currentProject.name;

View File

@@ -11,6 +11,8 @@ public class OrganizationConfiguration : IEntityTypeConfiguration<Organization>
builder.HasKey(o => o.Id); builder.HasKey(o => o.Id);
builder.Property(o => o.Name).HasMaxLength(200).IsRequired(); builder.Property(o => o.Name).HasMaxLength(200).IsRequired();
builder.Property(o => o.CreatedAt).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) builder.HasMany(o => o.Members)
.WithOne() .WithOne()

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -400,6 +400,10 @@ namespace MicCheck.Api.Migrations
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("InviteToken")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -407,6 +411,10 @@ namespace MicCheck.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("InviteToken")
.IsUnique()
.HasFilter("\"InviteToken\" IS NOT NULL");
b.ToTable("Organizations"); b.ToTable("Organizations");
}); });

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Organizations;
public record InviteTokenResponse(string Token);

View File

@@ -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);

View File

@@ -11,4 +11,5 @@ public class Organization
public ICollection<Project> Projects { get; init; } = []; public ICollection<Project> Projects { get; init; } = [];
public ICollection<OrganizationUser> Members { get; init; } = []; public ICollection<OrganizationUser> Members { get; init; } = [];
public ICollection<ApiKey> ApiKeys { get; init; } = []; public ICollection<ApiKey> ApiKeys { get; init; } = [];
public string? InviteToken { get; set; }
} }

View File

@@ -1,5 +1,6 @@
using MicCheck.Api.Audit; using MicCheck.Api.Audit;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Users;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
@@ -100,4 +101,68 @@ public class OrganizationService(MicCheckDbContext db, AuditService auditService
db.OrganizationUsers.Remove(member); db.OrganizationUsers.Remove(member);
await db.SaveChangesAsync(ct); 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);
}
} }

View File

@@ -89,6 +89,54 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(); 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}")] [HttpDelete("{id}/users/{userId}")]
public async Task<IActionResult> RemoveUser(int id, int userId, CancellationToken ct) public async Task<IActionResult> RemoveUser(int id, int userId, CancellationToken ct)
{ {