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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SettingsLayout from './components/SettingsLayout'
import OverviewPage from './pages/OverviewPage'
import APIKeysPage from './pages/APIKeysPage'
import TeamPage from './pages/TeamPage'
import UserGroupsPage from './pages/UserGroupsPage'
import BrandingPage from './pages/BrandingPage'
import EgressPage from './pages/EgressPage'
import SnippetsPage from './pages/SnippetsPage'
Expand Down Expand Up @@ -78,6 +79,7 @@ function AppRoutes() {
<Route index element={<Navigate to="api-keys" replace />} />
<Route path="api-keys" element={<APIKeysPage />} />
<Route path="team" element={<TeamPage />} />
<Route path="groups" element={<UserGroupsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="egress" element={<EgressPage />} />
<Route path="embed" element={<EmbedPage />} />
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/components/RouteDocumentTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const dashboardRoutes: { path: string; title: string }[] = [
{ path: '/dashboard/data-store', title: 'Data Store' },
{ path: '/dashboard/settings/api-keys', title: 'API Keys' },
{ path: '/dashboard/settings/team', title: 'Team' },
{ path: '/dashboard/settings/groups', title: 'User Groups' },
{ path: '/dashboard/settings/branding', title: 'Branding' },
{ path: '/dashboard/settings/egress', title: 'Egress Policy' },
{ path: '/dashboard/settings/embed', title: 'Embed' },
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/components/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import clsx from 'clsx'
const tabs = [
{ to: '/dashboard/settings/api-keys', label: 'API Keys' },
{ to: '/dashboard/settings/team', label: 'Team' },
{ to: '/dashboard/settings/groups', label: 'User Groups' },
{ to: '/dashboard/settings/branding', label: 'Branding' },
{ to: '/dashboard/settings/egress', label: 'Egress Policy' },
{ to: '/dashboard/settings/embed', label: 'Embed' },
Expand Down
45 changes: 45 additions & 0 deletions apps/admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
OrgMembership,
Branding,
TenantMember,
UserGroup,
IntegrationGroupGrant,
InviteToken,
UsageSummary,
APIKey,
Expand Down Expand Up @@ -463,6 +465,49 @@ export const api = {
return request('GET', '/v1/tenant/members/invites', undefined, 'apikey')
},

// User groups
async listUserGroups(): Promise<UserGroup[]> {
return request('GET', '/v1/tenant/groups', undefined, 'apikey')
},

async createUserGroup(name: string, description: string): Promise<UserGroup> {
return request('POST', '/v1/tenant/groups', { name, description }, 'apikey')
},

async deleteUserGroup(groupID: string): Promise<void> {
return request('DELETE', `/v1/tenant/groups/${groupID}`, undefined, 'apikey')
},

async addUserGroupMember(groupID: string, userID: string): Promise<void> {
return request('POST', `/v1/tenant/groups/${groupID}/members`, { user_id: userID }, 'apikey')
},

async removeUserGroupMember(groupID: string, userID: string): Promise<void> {
return request('DELETE', `/v1/tenant/groups/${groupID}/members`, { user_id: userID }, 'apikey')
},

async grantIntegrationToGroup(groupID: string, credentialProfileID: string): Promise<void> {
return request(
'POST',
`/v1/tenant/groups/${groupID}/integrations`,
{ credential_profile_id: credentialProfileID },
'apikey',
)
},

async revokeIntegrationFromGroup(groupID: string, credentialProfileID: string): Promise<void> {
return request(
'DELETE',
`/v1/tenant/groups/${groupID}/integrations`,
{ credential_profile_id: credentialProfileID },
'apikey',
)
},

async listIntegrationGrants(credentialProfileID: string): Promise<IntegrationGroupGrant[]> {
return request('GET', `/v1/integrations/configured/${credentialProfileID}/groups`, undefined, 'apikey')
},

// Usage
async getUsage(window: string): Promise<UsageSummary> {
return request('GET', `/v1/tenant/usage?window=${window}`, undefined, 'apikey')
Expand Down
3 changes: 3 additions & 0 deletions apps/admin/src/pages/APIKeysPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ export default function APIKeysPage() {
</label>
))}
</div>
<p className="mt-1 text-xs text-gray-500">
The key inherits your user groups, so it can only use integrations granted to them.
</p>
</div>
<button
type="submit"
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/pages/TeamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { api } from '../lib/api'
import type { TenantMember, InviteToken } from '../types'
import CopyBox from '../components/CopyBox'

const ROLES = ['invoke', 'manage', 'admin']
const ROLES = ['owner', 'admin', 'integration_manager', 'manage', 'member', 'invoke', 'viewer']

export default function TeamPage() {
const [members, setMembers] = useState<TenantMember[]>([])
Expand Down
226 changes: 226 additions & 0 deletions apps/admin/src/pages/UserGroupsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { useEffect, useState, type FormEvent } from 'react'
import { Trash2, X } from 'lucide-react'
import { api } from '../lib/api'
import type { IntegrationConfig, TenantMember, UserGroup } from '../types'

export default function UserGroupsPage() {
const [groups, setGroups] = useState<UserGroup[]>([])
const [members, setMembers] = useState<TenantMember[]>([])
const [integrations, setIntegrations] = useState<IntegrationConfig[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')

const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [creating, setCreating] = useState(false)

const load = async () => {
try {
const [g, m, i] = await Promise.all([
api.listUserGroups(),
api.listMembers(),
api.listConfigured(undefined, undefined, undefined, 'all'),
])
setGroups(g)
setMembers(m)
setIntegrations(i)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load user groups')
} finally {
setLoading(false)
}
}

useEffect(() => { load() }, [])

const run = async (fn: () => Promise<void>) => {
setError('')
try {
await fn()
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Request failed')
}
}

const handleCreate = async (e: FormEvent) => {
e.preventDefault()
setCreating(true)
await run(async () => {
await api.createUserGroup(name.trim(), description.trim())
setName('')
setDescription('')
})
setCreating(false)
}

const integrationLabel = (profileID: string) => {
const cfg = integrations.find((i) => i.id === profileID)
return cfg ? `${cfg.provider} · ${cfg.alias}` : profileID
}

return (
<div>
<h1 className="mb-2 text-2xl font-bold text-gray-900">User Groups</h1>
<p className="mb-6 text-sm text-gray-500">
Grant integrations to groups. Members of a group — and the API keys they create — are the
only non-admin principals allowed to use those integrations, including from snippet code.
</p>

{error && <div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{error}</div>}

<form onSubmit={handleCreate} className="mb-8 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-base font-semibold text-gray-900">Create Group</h2>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="support-engineers"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none"
/>
</div>
<div className="flex-1">
<label className="mb-1 block text-sm font-medium text-gray-700">Description</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none"
/>
</div>
<button
type="submit"
disabled={creating}
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Group'}
</button>
</div>
</form>

{loading ? (
<p className="text-sm text-gray-500">Loading...</p>
) : groups.length === 0 ? (
<p className="text-sm text-gray-500">No groups yet.</p>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.id} className="rounded-lg border border-gray-200 bg-white shadow-sm">
<div className="flex items-start justify-between border-b border-gray-200 px-4 py-3">
<div>
<h2 className="text-base font-semibold text-gray-900">{group.name}</h2>
{group.description && (
<p className="text-sm text-gray-500">{group.description}</p>
)}
</div>
<button
onClick={() => {
if (confirm(`Delete group "${group.name}"?`)) {
run(() => api.deleteUserGroup(group.id))
}
}}
className="text-red-500 hover:text-red-700"
title="Delete group"
>
<Trash2 size={16} />
</button>
</div>

<div className="grid gap-6 p-4 md:grid-cols-2">
<div>
<h3 className="mb-2 text-sm font-medium text-gray-700">Members</h3>
<div className="mb-3 flex flex-wrap gap-2">
{(group.members ?? []).length === 0 ? (
<span className="text-sm text-gray-500">No members yet.</span>
) : (
(group.members ?? []).map((m) => (
<span
key={m.user_id}
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700"
>
{m.email || m.user_id}
<button
onClick={() => run(() => api.removeUserGroupMember(group.id, m.user_id))}
className="text-gray-500 hover:text-red-600"
title="Remove member"
>
<X size={12} />
</button>
</span>
))
)}
</div>
<select
value=""
onChange={(e) => {
const userID = e.target.value
if (userID) run(() => api.addUserGroupMember(group.id, userID))
}}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none"
>
<option value="">Add member…</option>
{members
.filter((m) => !(group.members ?? []).some((gm) => gm.user_id === m.user_id))
.map((m) => (
<option key={m.user_id} value={m.user_id}>{m.email}</option>
))}
</select>
</div>

<div>
<h3 className="mb-2 text-sm font-medium text-gray-700">Integrations</h3>
<div className="mb-3 flex flex-wrap gap-2">
{(group.integration_grants ?? []).length === 0 ? (
<span className="text-sm text-gray-500">No integrations granted.</span>
) : (
(group.integration_grants ?? []).map((g) => (
<span
key={g.credential_profile_id}
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700"
>
{integrationLabel(g.credential_profile_id)}
<button
onClick={() =>
run(() => api.revokeIntegrationFromGroup(group.id, g.credential_profile_id))
}
className="text-gray-500 hover:text-red-600"
title="Revoke integration"
>
<X size={12} />
</button>
</span>
))
)}
</div>
<select
value=""
onChange={(e) => {
const profileID = e.target.value
if (profileID) run(() => api.grantIntegrationToGroup(group.id, profileID))
}}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none"
>
<option value="">Grant integration…</option>
{integrations
.filter(
(i) =>
!(group.integration_grants ?? []).some(
(g) => g.credential_profile_id === i.id,
),
)
.map((i) => (
<option key={i.id} value={i.id}>{i.provider} · {i.alias}</option>
))}
</select>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
25 changes: 25 additions & 0 deletions apps/admin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ export interface TenantMember {
invited_at: string
}

export interface UserGroupMember {
group_id: string
user_id: string
email?: string
added_at: string
}

export interface IntegrationGroupGrant {
group_id: string
group_name?: string
credential_profile_id: string
granted_at: string
}

export interface UserGroup {
id: string
tenant_id: string
name: string
description: string
created_at: string
updated_at: string
members: UserGroupMember[] | null
integration_grants: IntegrationGroupGrant[] | null
}

export interface InviteToken {
id: string
tenant_id: string
Expand Down
4 changes: 4 additions & 0 deletions platform-libraries/bun/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

const PROXY_URL = process.env.VELANE_PROXY_URL ?? ''
const TENANT_ID = process.env.VELANE_TENANT_ID ?? ''
// Signed by the control plane; carries the invoking user's group access so the
// proxy can enforce integration grants. Opaque and unforgeable from here.
const CALLER_TOKEN = process.env.VELANE_CALLER_TOKEN ?? ''

export class IntegrationClient {
constructor(
Expand All @@ -37,6 +40,7 @@ export class IntegrationClient {
headers: {
'Content-Type': 'application/json',
'X-Velane-Tenant': TENANT_ID,
...(CALLER_TOKEN ? { 'X-Velane-Caller': CALLER_TOKEN } : {}),
...(this.opts?.alias ? { 'X-Velane-Integration-Alias': this.opts.alias } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
Expand Down
Loading
Loading