diff --git a/PERMISSION_UI_PLAN.md b/PERMISSION_UI_PLAN.md
new file mode 100644
index 000000000..fb879bf7f
--- /dev/null
+++ b/PERMISSION_UI_PLAN.md
@@ -0,0 +1,46 @@
+# Permission And UI Plan
+
+## Goals
+
+- Project roles grant usable project access without requiring system scopes.
+- The server publishes effective project scopes so API authorization, routes, menus, and controls share one source of truth.
+- Read permission shows information; write permission alone exposes mutation controls.
+- Project members select from project-approved, enabled models without viewing global Channel configuration.
+
+## Role Presets
+
+New projects create these project roles. Invitations bind to a concrete project role when created.
+
+| Role | Scope set | Intended use |
+| --- | --- | --- |
+| Project Admin | `read/write_users`, `read/write_roles`, `read/write_api_keys`, `read/write_prompts`, `read/write_requests` | Manage the project, its members, and its resources. |
+| Developer | `read/write_api_keys`, `read/write_prompts`, `write_requests` | Use the project, manage API keys and prompts, and use Playground. Personal request visibility is not treated as project-wide request access. |
+| Viewer | `read_prompts`, `read_requests` | Inspect permitted project data without changing it. |
+
+System Channels, Models, Data Storage, Settings, and global analytics remain system-level resources. A project role never receives `read_channels` or `write_channels` merely to use Playground.
+
+## Authorization Contract
+
+1. `UserProjectInfo.effectiveScopes` is the union of direct membership scopes and roles assigned in that project.
+2. Frontend navigation, route guards, and mutation affordances use `effectiveScopes` for the selected project.
+3. The server continues to authorize every query and mutation. A hidden control is never an authorization boundary.
+4. Invitation creation validates that the selected role belongs to the invitation project and that the inviter may grant its scopes. Registration creates membership and role assignment in one transaction.
+
+## UI Contract
+
+| Capability | UI behavior |
+| --- | --- |
+| Read resource | Show route, list, detail values, and static state. |
+| Write resource | Show create, edit, delete, bulk actions, switches, inline editors, and ordering controls. |
+| Read-only Channel/Model/Prompt | Render status as a non-interactive badge. Hide mutation columns and controls. |
+| Project Playground | Query project-profile-approved enabled models only. Disabled or excluded Channels are never listed. |
+
+The system Channel list remains an audit/configuration view: a system user with `read_channels` can see disabled Channels as static rows. The project model picker is a separate availability view and excludes them at the API boundary.
+
+## Delivery Order
+
+1. Add effective scopes and correct default project roles.
+2. Bind invitations to a validated project role.
+3. Switch frontend permission consumers to effective scopes and align the Playground navigation guard.
+4. Gate read-only controls in Channel, Model, Prompt, and API Key surfaces.
+5. Add focused tests for role assignment, effective scopes, invitation registration, and read-only UI behavior.
diff --git a/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx b/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx
index 6948ec32d..205d626d5 100644
--- a/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx
+++ b/frontend/src/features/apikeys/components/apikeys-create-dialog.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
@@ -12,11 +12,14 @@ import { useApiKeysContext } from '../context/apikeys-context';
import { useCreateApiKey } from '../data/apikeys';
import { CreateApiKeyInput, createApiKeyInputSchema } from '../data/schema';
import { ScopesSelect } from '@/components/scopes-select';
+import { usePermissions } from '@/hooks/usePermissions';
export function ApiKeysCreateDialog() {
const { t } = useTranslation();
const { isDialogOpen, closeDialog, openDialog, setSelectedApiKey } = useApiKeysContext();
const createApiKey = useCreateApiKey();
+ const { hasProjectScope } = usePermissions();
+ const canCreateProjectApiKey = hasProjectScope('write_users') && hasProjectScope('write_roles');
const [isSubmitting, setIsSubmitting] = useState(false);
const [ipRestrictionEnabled, setIPRestrictionEnabled] = useState(false);
const [ipInput, setIpInput] = useState('');
@@ -27,7 +30,7 @@ export function ApiKeysCreateDialog() {
resolver: zodResolver(createApiKeyInputSchema),
defaultValues: {
name: '',
- type: 'user',
+ type: 'personal',
scopes: undefined,
allowedIps: [],
},
@@ -35,6 +38,12 @@ export function ApiKeysCreateDialog() {
const apiKeyType = form.watch('type');
+ useEffect(() => {
+ if (!canCreateProjectApiKey && form.getValues('type') === 'user') {
+ form.setValue('type', 'personal');
+ }
+ }, [canCreateProjectApiKey, form]);
+
const onSubmit = async (data: CreateApiKeyInput) => {
setIsSubmitting(true);
try {
@@ -100,12 +109,14 @@ export function ApiKeysCreateDialog() {
{t('apikeys.dialogs.fields.type.label')}
-
-
-
-
- {t('apikeys.dialogs.fields.type.user')}
-
+ {canCreateProjectApiKey && (
+
+
+
+
+ {t('apikeys.dialogs.fields.type.user')}
+
+ )}
diff --git a/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx b/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx
index 8b007c9d8..696c23f1d 100644
--- a/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx
+++ b/frontend/src/features/apikeys/components/apikeys-primary-buttons.tsx
@@ -1,6 +1,7 @@
import { IconPlus, IconTemplate } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
+import { PermissionGuard } from '@/components/permission-guard';
import { useApiKeysContext } from '../context/apikeys-context';
export function ApiKeysPrimaryButtons() {
@@ -9,14 +10,16 @@ export function ApiKeysPrimaryButtons() {
return (