Merge pull request #61 from abhi1693/feat/organizations

feat(orgs): introduce organizations, invites, and board access controls
This commit is contained in:
Abhimanyu Saharan
2026-02-08 23:03:24 +05:30
committed by GitHub
91 changed files with 9691 additions and 1517 deletions

View File

@@ -162,6 +162,7 @@ Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` pub
If you see repeated proxy errors (often `ECONNRESET`), make sure your dev server hostname and browser URL match (e.g. `localhost` vs `127.0.0.1`), and that your origin is included in `allowedDevOrigins`.
Notes:
- Local dev should work via `http://localhost:3000` and `http://127.0.0.1:3000`.
- LAN dev should work via the configured LAN IP (e.g. `http://192.168.1.101:3000`) **only** if you bind the dev server to all interfaces (`npm run dev:lan`).
- If you bind Next to `127.0.0.1` only, remote LAN clients wont connect.

View File

@@ -3,7 +3,8 @@ import { clerkSetup } from "@clerk/testing/cypress";
export default defineConfig({
env: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
// Optional overrides.
CLERK_ORIGIN: process.env.CYPRESS_CLERK_ORIGIN,
CLERK_TEST_EMAIL: process.env.CYPRESS_CLERK_TEST_EMAIL,

View File

@@ -53,6 +53,7 @@ import type {
ListTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams,
ListTasksApiV1AgentBoardsBoardIdTasksGetParams,
OkResponse,
SoulUpdateRequest,
TaskCommentCreate,
TaskCommentRead,
TaskCreate,
@@ -3035,6 +3036,449 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
queryClient,
);
};
/**
* @summary Get Agent Soul
*/
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 =
{
data: string;
status: 200;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess =
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse200 & {
headers: Headers;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError =
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse422 & {
headers: Headers;
};
export type getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse =
| getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseSuccess
| getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponseError;
export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetUrl = (
boardId: string,
agentId: string,
) => {
return `/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`;
};
export const getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet = async (
boardId: string,
agentId: string,
options?: RequestInit,
): Promise<getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse> => {
return customFetch<getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetResponse>(
getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetUrl(
boardId,
agentId,
),
{
...options,
method: "GET",
},
);
};
export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryKey =
(boardId: string, agentId: string) => {
return [`/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`] as const;
};
export const getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryOptions =
<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryKey(
boardId,
agentId,
);
const queryFn: QueryFunction<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>
> = ({ signal }) =>
getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet(
boardId,
agentId,
{ signal, ...requestOptions },
);
return {
queryKey,
queryFn,
enabled: !!(boardId && agentId),
...queryOptions,
} as UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryResult =
NonNullable<
Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>
>;
export type GetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryError =
HTTPValidationError;
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get Agent Soul
*/
export function useGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet<
TData = Awaited<
ReturnType<typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet>
>,
TError = HTTPValidationError,
>(
boardId: string,
agentId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof getAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulGetQueryOptions(
boardId,
agentId,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Update Agent Soul
*/
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse200 =
{
data: OkResponse;
status: 200;
};
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse422 =
{
data: HTTPValidationError;
status: 422;
};
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseSuccess =
updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse200 & {
headers: Headers;
};
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseError =
updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse422 & {
headers: Headers;
};
export type updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse =
| updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseSuccess
| updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponseError;
export const getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutUrl =
(boardId: string, agentId: string) => {
return `/api/v1/agent/boards/${boardId}/agents/${agentId}/soul`;
};
export const updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut =
async (
boardId: string,
agentId: string,
soulUpdateRequest: SoulUpdateRequest,
options?: RequestInit,
): Promise<updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse> => {
return customFetch<updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutResponse>(
getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutUrl(
boardId,
agentId,
),
{
...options,
method: "PUT",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(soulUpdateRequest),
},
);
};
export const getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
>
>,
TError,
{ boardId: string; agentId: string; data: SoulUpdateRequest },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<
ReturnType<
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
>
>,
TError,
{ boardId: string; agentId: string; data: SoulUpdateRequest },
TContext
> => {
const mutationKey = [
"updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut",
];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<
ReturnType<
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
>
>,
{ boardId: string; agentId: string; data: SoulUpdateRequest }
> = (props) => {
const { boardId, agentId, data } = props ?? {};
return updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut(
boardId,
agentId,
data,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationResult =
NonNullable<
Awaited<
ReturnType<
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
>
>
>;
export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationBody =
SoulUpdateRequest;
export type UpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationError =
HTTPValidationError;
/**
* @summary Update Agent Soul
*/
export const useUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
>
>,
TError,
{ boardId: string; agentId: string; data: SoulUpdateRequest },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<
typeof updateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPut
>
>,
TError,
{ boardId: string; agentId: string; data: SoulUpdateRequest },
TContext
> => {
return useMutation(
getUpdateAgentSoulApiV1AgentBoardsBoardIdAgentsAgentIdSoulPutMutationOptions(
options,
),
queryClient,
);
};
/**
* @summary Ask User Via Gateway Main
*/

View File

@@ -10,6 +10,7 @@ export interface BoardGroupRead {
slug: string;
description?: string | null;
id: string;
organization_id: string;
created_at: string;
updated_at: string;
}

View File

@@ -18,6 +18,7 @@ export interface BoardRead {
goal_confirmed?: boolean;
goal_source?: string | null;
id: string;
organization_id: string;
created_at: string;
updated_at: string;
}

View File

@@ -11,6 +11,7 @@ export interface GatewayRead {
main_session_key: string;
workspace_root: string;
id: string;
organization_id: string;
token?: string | null;
created_at: string;
updated_at: string;

View File

@@ -131,6 +131,8 @@ export * from "./limitOffsetPageTypeVarCustomizedBoardGroupRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
export * from "./limitOffsetPageTypeVarCustomizedBoardRead";
export * from "./limitOffsetPageTypeVarCustomizedGatewayRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationInviteRead";
export * from "./limitOffsetPageTypeVarCustomizedOrganizationMemberRead";
export * from "./limitOffsetPageTypeVarCustomizedTaskCommentRead";
export * from "./limitOffsetPageTypeVarCustomizedTaskRead";
export * from "./listActivityApiV1ActivityGetParams";
@@ -147,6 +149,8 @@ export * from "./listBoardsApiV1AgentBoardsGetParams";
export * from "./listBoardsApiV1BoardsGetParams";
export * from "./listGatewaysApiV1GatewaysGetParams";
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
export * from "./listOrgInvitesApiV1OrganizationsMeInvitesGetParams";
export * from "./listOrgMembersApiV1OrganizationsMeMembersGetParams";
export * from "./listSessionsApiV1GatewaySessionsGet200";
export * from "./listSessionsApiV1GatewaySessionsGetParams";
export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams";
@@ -155,11 +159,29 @@ export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams"
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
export * from "./listTasksApiV1BoardsBoardIdTasksGetParams";
export * from "./okResponse";
export * from "./organizationActiveUpdate";
export * from "./organizationBoardAccessRead";
export * from "./organizationBoardAccessSpec";
export * from "./organizationCreate";
export * from "./organizationInviteAccept";
export * from "./organizationInviteCreate";
export * from "./organizationInviteRead";
export * from "./organizationListItem";
export * from "./organizationMemberAccessUpdate";
export * from "./organizationMemberRead";
export * from "./organizationMemberUpdate";
export * from "./organizationRead";
export * from "./organizationUserRead";
export * from "./readyzReadyzGet200";
export * from "./searchApiV1SoulsDirectorySearchGetParams";
export * from "./sendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePost200";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostBody";
export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParams";
export * from "./soulsDirectoryMarkdownResponse";
export * from "./soulsDirectorySearchResponse";
export * from "./soulsDirectorySoulRef";
export * from "./soulUpdateRequest";
export * from "./streamAgentsApiV1AgentsStreamGetParams";
export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams";
export * from "./streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGetParams";

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { OrganizationInviteRead } from "./organizationInviteRead";
export interface LimitOffsetPageTypeVarCustomizedOrganizationInviteRead {
items: OrganizationInviteRead[];
/** @minimum 0 */
total: number;
/** @minimum 1 */
limit: number;
/** @minimum 0 */
offset: number;
}

View File

@@ -0,0 +1,17 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { OrganizationMemberRead } from "./organizationMemberRead";
export interface LimitOffsetPageTypeVarCustomizedOrganizationMemberRead {
items: OrganizationMemberRead[];
/** @minimum 0 */
total: number;
/** @minimum 1 */
limit: number;
/** @minimum 0 */
offset: number;
}

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type ListOrgInvitesApiV1OrganizationsMeInvitesGetParams = {
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
};

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type ListOrgMembersApiV1OrganizationsMeMembersGetParams = {
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
};

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationActiveUpdate {
organization_id: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationBoardAccessRead {
id: string;
board_id: string;
can_read: boolean;
can_write: boolean;
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationBoardAccessSpec {
board_id: string;
can_read?: boolean;
can_write?: boolean;
}

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationCreate {
name: string;
}

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationInviteAccept {
token: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { OrganizationBoardAccessSpec } from "./organizationBoardAccessSpec";
export interface OrganizationInviteCreate {
invited_email: string;
role?: string;
all_boards_read?: boolean;
all_boards_write?: boolean;
board_access?: OrganizationBoardAccessSpec[];
}

View File

@@ -0,0 +1,21 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationInviteRead {
id: string;
organization_id: string;
invited_email: string;
role: string;
all_boards_read: boolean;
all_boards_write: boolean;
token: string;
created_by_user_id?: string | null;
accepted_by_user_id?: string | null;
accepted_at?: string | null;
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationListItem {
id: string;
name: string;
role: string;
is_active: boolean;
}

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { OrganizationBoardAccessSpec } from "./organizationBoardAccessSpec";
export interface OrganizationMemberAccessUpdate {
all_boards_read?: boolean;
all_boards_write?: boolean;
board_access?: OrganizationBoardAccessSpec[];
}

View File

@@ -0,0 +1,21 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { OrganizationBoardAccessRead } from "./organizationBoardAccessRead";
import type { OrganizationUserRead } from "./organizationUserRead";
export interface OrganizationMemberRead {
id: string;
organization_id: string;
user_id: string;
role: string;
all_boards_read: boolean;
all_boards_write: boolean;
created_at: string;
updated_at: string;
user?: OrganizationUserRead | null;
board_access?: OrganizationBoardAccessRead[];
}

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationMemberUpdate {
role?: string | null;
}

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationRead {
id: string;
name: string;
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface OrganizationUserRead {
id: string;
email?: string | null;
name?: string | null;
preferred_name?: string | null;
}

View File

@@ -0,0 +1,18 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export type SearchApiV1SoulsDirectorySearchGetParams = {
/**
* @minLength 0
*/
q?: string;
/**
* @minimum 1
* @maximum 100
*/
limit?: number;
};

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface SoulUpdateRequest {
content: string;
source_url?: string | null;
reason?: string | null;
}

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface SoulsDirectoryMarkdownResponse {
handle: string;
slug: string;
content: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { SoulsDirectorySoulRef } from "./soulsDirectorySoulRef";
export interface SoulsDirectorySearchResponse {
items: SoulsDirectorySoulRef[];
}

View File

@@ -0,0 +1,13 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
export interface SoulsDirectorySoulRef {
handle: string;
slug: string;
page_url: string;
raw_md_url: string;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,727 @@
/**
* Generated by orval v8.2.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import { useQuery } from "@tanstack/react-query";
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type {
HTTPValidationError,
SearchApiV1SoulsDirectorySearchGetParams,
SoulsDirectoryMarkdownResponse,
SoulsDirectorySearchResponse,
} from ".././model";
import { customFetch } from "../../mutator";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary Search
*/
export type searchApiV1SoulsDirectorySearchGetResponse200 = {
data: SoulsDirectorySearchResponse;
status: 200;
};
export type searchApiV1SoulsDirectorySearchGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type searchApiV1SoulsDirectorySearchGetResponseSuccess =
searchApiV1SoulsDirectorySearchGetResponse200 & {
headers: Headers;
};
export type searchApiV1SoulsDirectorySearchGetResponseError =
searchApiV1SoulsDirectorySearchGetResponse422 & {
headers: Headers;
};
export type searchApiV1SoulsDirectorySearchGetResponse =
| searchApiV1SoulsDirectorySearchGetResponseSuccess
| searchApiV1SoulsDirectorySearchGetResponseError;
export const getSearchApiV1SoulsDirectorySearchGetUrl = (
params?: SearchApiV1SoulsDirectorySearchGetParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/v1/souls-directory/search?${stringifiedParams}`
: `/api/v1/souls-directory/search`;
};
export const searchApiV1SoulsDirectorySearchGet = async (
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: RequestInit,
): Promise<searchApiV1SoulsDirectorySearchGetResponse> => {
return customFetch<searchApiV1SoulsDirectorySearchGetResponse>(
getSearchApiV1SoulsDirectorySearchGetUrl(params),
{
...options,
method: "GET",
},
);
};
export const getSearchApiV1SoulsDirectorySearchGetQueryKey = (
params?: SearchApiV1SoulsDirectorySearchGetParams,
) => {
return [
`/api/v1/souls-directory/search`,
...(params ? [params] : []),
] as const;
};
export const getSearchApiV1SoulsDirectorySearchGetQueryOptions = <
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getSearchApiV1SoulsDirectorySearchGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
> = ({ signal }) =>
searchApiV1SoulsDirectorySearchGet(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type SearchApiV1SoulsDirectorySearchGetQueryResult = NonNullable<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
>;
export type SearchApiV1SoulsDirectorySearchGetQueryError = HTTPValidationError;
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params: undefined | SearchApiV1SoulsDirectorySearchGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Search
*/
export function useSearchApiV1SoulsDirectorySearchGet<
TData = Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError = HTTPValidationError,
>(
params?: SearchApiV1SoulsDirectorySearchGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof searchApiV1SoulsDirectorySearchGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getSearchApiV1SoulsDirectorySearchGetQueryOptions(
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get Markdown
*/
export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse200 = {
data: SoulsDirectoryMarkdownResponse;
status: 200;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseSuccess =
getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse200 & {
headers: Headers;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseError =
getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse422 & {
headers: Headers;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse =
| getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseSuccess
| getMarkdownApiV1SoulsDirectoryHandleSlugGetResponseError;
export const getGetMarkdownApiV1SoulsDirectoryHandleSlugGetUrl = (
handle: string,
slug: string,
) => {
return `/api/v1/souls-directory/${handle}/${slug}`;
};
export const getMarkdownApiV1SoulsDirectoryHandleSlugGet = async (
handle: string,
slug: string,
options?: RequestInit,
): Promise<getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse> => {
return customFetch<getMarkdownApiV1SoulsDirectoryHandleSlugGetResponse>(
getGetMarkdownApiV1SoulsDirectoryHandleSlugGetUrl(handle, slug),
{
...options,
method: "GET",
},
);
};
export const getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryKey = (
handle: string,
slug: string,
) => {
return [`/api/v1/souls-directory/${handle}/${slug}`] as const;
};
export const getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryOptions = <
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryKey(handle, slug);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>
> = ({ signal }) =>
getMarkdownApiV1SoulsDirectoryHandleSlugGet(handle, slug, {
signal,
...requestOptions,
});
return {
queryKey,
queryFn,
enabled: !!(handle && slug),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>
>;
export type GetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryError =
HTTPValidationError;
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get Markdown
*/
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetMarkdownApiV1SoulsDirectoryHandleSlugGetQueryOptions(
handle,
slug,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get Markdown
*/
export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse200 = {
data: SoulsDirectoryMarkdownResponse;
status: 200;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse422 = {
data: HTTPValidationError;
status: 422;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseSuccess =
getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse200 & {
headers: Headers;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseError =
getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse422 & {
headers: Headers;
};
export type getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse =
| getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseSuccess
| getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponseError;
export const getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetUrl = (
handle: string,
slug: string,
) => {
return `/api/v1/souls-directory/${handle}/${slug}.md`;
};
export const getMarkdownApiV1SoulsDirectoryHandleSlugMdGet = async (
handle: string,
slug: string,
options?: RequestInit,
): Promise<getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse> => {
return customFetch<getMarkdownApiV1SoulsDirectoryHandleSlugMdGetResponse>(
getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetUrl(handle, slug),
{
...options,
method: "GET",
},
);
};
export const getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryKey = (
handle: string,
slug: string,
) => {
return [`/api/v1/souls-directory/${handle}/${slug}.md`] as const;
};
export const getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryOptions = <
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryKey(handle, slug);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>>
> = ({ signal }) =>
getMarkdownApiV1SoulsDirectoryHandleSlugMdGet(handle, slug, {
signal,
...requestOptions,
});
return {
queryKey,
queryFn,
enabled: !!(handle && slug),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type GetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>>
>;
export type GetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryError =
HTTPValidationError;
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get Markdown
*/
export function useGetMarkdownApiV1SoulsDirectoryHandleSlugMdGet<
TData = Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError = HTTPValidationError,
>(
handle: string,
slug: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof getMarkdownApiV1SoulsDirectoryHandleSlugMdGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getGetMarkdownApiV1SoulsDirectoryHandleSlugMdGetQueryOptions(
handle,
slug,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -22,6 +22,10 @@ import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type {
ActivityEventRead,
AgentRead,
@@ -80,6 +84,20 @@ export default function AgentDetailPage() {
const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -88,7 +106,7 @@ export default function AgentDetailPage() {
ApiError
>(agentId ?? "", {
query: {
enabled: Boolean(isSignedIn && agentId),
enabled: Boolean(isSignedIn && isAdmin && agentId),
refetchInterval: 30_000,
refetchOnMount: "always",
retry: false,
@@ -102,7 +120,7 @@ export default function AgentDetailPage() {
{ limit: 200 },
{
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000,
retry: false,
},
@@ -114,7 +132,7 @@ export default function AgentDetailPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 60_000,
refetchOnMount: "always",
retry: false,
@@ -186,192 +204,203 @@ export default function AgentDetailPage() {
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Agents
</p>
<h1 className="text-2xl font-semibold text-strong">
{agent?.name ?? "Agent"}
</h1>
<p className="text-sm text-muted">
Review agent health, session binding, and recent activity.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push("/agents")}>
Back to agents
</Button>
{agent ? (
<Link
href={`/agents/${agent.id}/edit`}
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
) : null}
{agent ? (
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
) : null}
{!isAdmin ? (
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-6 py-5 text-sm text-muted">
Only organization owners and admins can access agents.
</div>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Loading agent details
</div>
) : agent ? (
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Overview
</p>
<p className="mt-1 text-lg font-semibold text-strong">
{agent.name}
</p>
</div>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agent ID
</p>
<p className="mt-1 text-sm text-muted">{agent.id}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session key
</p>
<p className="mt-1 text-sm text-muted">
{agent.openclaw_session_id ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">
Gateway main (no board)
</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
>
{linkedBoard.name}
</Link>
) : (
<p className="mt-1 text-sm text-strong"></p>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen
</p>
<p className="mt-1 text-sm text-strong">
{formatRelative(agent.last_seen_at)}
</p>
<p className="text-xs text-quiet">
{formatTimestamp(agent.last_seen_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Updated
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.updated_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Created
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.created_at)}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Heartbeat window</span>
<span>{formatRelative(agent.last_seen_at)}</span>
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>
{agent.openclaw_session_id ? "Bound" : "Unbound"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agentStatus}</span>
</div>
</div>
</div>
) : (
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Agents
</p>
<h1 className="text-2xl font-semibold text-strong">
{agent?.name ?? "Agent"}
</h1>
<p className="text-sm text-muted">
Review agent health, session binding, and recent activity.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
{agent ? (
<Link
href={`/agents/${agent.id}/edit`}
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
) : null}
{agent ? (
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
) : null}
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Activity
</p>
<p className="text-xs text-quiet">
{agentEvents.length} events
</p>
</div>
<div className="space-y-3">
{agentEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
No activity yet for this agent.
</div>
) : (
agentEvents.map((event) => (
<div
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Loading agent details
</div>
) : agent ? (
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Overview
</p>
<p className="mt-1 text-xs text-quiet">
{formatTimestamp(event.created_at)}
<p className="mt-1 text-lg font-semibold text-strong">
{agent.name}
</p>
</div>
))
)}
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agent ID
</p>
<p className="mt-1 text-sm text-muted">{agent.id}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session key
</p>
<p className="mt-1 text-sm text-muted">
{agent.openclaw_session_id ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Board
</p>
{agent.is_gateway_main ? (
<p className="mt-1 text-sm text-strong">
Gateway main (no board)
</p>
) : linkedBoard ? (
<Link
href={`/boards/${linkedBoard.id}`}
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
>
{linkedBoard.name}
</Link>
) : (
<p className="mt-1 text-sm text-strong"></p>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen
</p>
<p className="mt-1 text-sm text-strong">
{formatRelative(agent.last_seen_at)}
</p>
<p className="text-xs text-quiet">
{formatTimestamp(agent.last_seen_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Updated
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.updated_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Created
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.created_at)}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agentStatus} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Heartbeat window</span>
<span>{formatRelative(agent.last_seen_at)}</span>
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>
{agent.openclaw_session_id ? "Bound" : "Unbound"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agentStatus}</span>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Activity
</p>
<p className="text-xs text-quiet">
{agentEvents.length} events
</p>
</div>
<div className="space-y-3">
{agentEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
No activity yet for this agent.
</div>
) : (
agentEvents.map((event) => (
<div
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
</p>
<p className="mt-1 text-xs text-quiet">
{formatTimestamp(event.created_at)}
</p>
</div>
))
)}
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Agent not found.
</div>
)}
</div>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Agent not found.
</div>
)}
</div>
)}
</SignedIn>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>

View File

@@ -13,6 +13,10 @@ import {
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import { useCreateAgentApiV1AgentsPost } from "@/api/generated/agents/agents";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -80,6 +84,20 @@ export default function NewAgentPage() {
const router = useRouter();
const { isSignedIn } = useAuth();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState("");
const [boardId, setBoardId] = useState<string>("");
const [heartbeatEvery, setHeartbeatEvery] = useState("10m");
@@ -95,7 +113,7 @@ export default function NewAgentPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
},
});
@@ -182,193 +200,204 @@ export default function NewAgentPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Basic configuration
</p>
<div className="mt-4 space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Agent name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot"
disabled={isLoading}
/>
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can create agents.
</div>
) : (
<form
onSubmit={handleSubmit}
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Basic configuration
</p>
<div className="mt-4 space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Agent name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Role
</label>
<Input
value={identityProfile.role}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
role: event.target.value,
}))
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select board"
value={displayBoardId}
onValueChange={setBoardId}
options={getBoardOptions(boards)}
placeholder="Select board"
searchPlaceholder="Search boards..."
emptyMessage="No matching boards."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={boards.length === 0}
/>
{boards.length === 0 ? (
<p className="text-xs text-slate-500">
Create a board before adding agents.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Emoji
</label>
<Select
value={identityProfile.emoji}
onValueChange={(value) =>
setIdentityProfile((current) => ({
...current,
emoji: value,
}))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select emoji" />
</SelectTrigger>
<SelectContent>
{EMOJI_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.glyph} {option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Personality & behavior
</p>
<div className="mt-4 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Role
Communication style
</label>
<Input
value={identityProfile.role}
value={identityProfile.communication_style}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
role: event.target.value,
communication_style: event.target.value,
}))
}
placeholder="e.g. Founder, Social Media Manager"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Soul template
</label>
<Textarea
value={soulTemplate}
onChange={(event) =>
setSoulTemplate(event.target.value)
}
rows={10}
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications
</p>
<div className="mt-4 grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board <span className="text-red-500">*</span>
Interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target
</label>
<SearchableSelect
ariaLabel="Select board"
value={displayBoardId}
onValueChange={setBoardId}
options={getBoardOptions(boards)}
placeholder="Select board"
searchPlaceholder="Search boards..."
emptyMessage="No matching boards."
ariaLabel="Select heartbeat target"
value={heartbeatTarget}
onValueChange={setHeartbeatTarget}
options={HEARTBEAT_TARGET_OPTIONS}
placeholder="Select target"
searchPlaceholder="Search targets..."
emptyMessage="No matching targets."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={boards.length === 0}
/>
{boards.length === 0 ? (
<p className="text-xs text-slate-500">
Create a board before adding agents.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Emoji
</label>
<Select
value={identityProfile.emoji}
onValueChange={(value) =>
setIdentityProfile((current) => ({
...current,
emoji: value,
}))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select emoji" />
</SelectTrigger>
<SelectContent>
{EMOJI_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.glyph} {option.label}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Personality & behavior
</p>
<div className="mt-4 space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Communication style
</label>
<Input
value={identityProfile.communication_style}
onChange={(event) =>
setIdentityProfile((current) => ({
...current,
communication_style: event.target.value,
}))
}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Soul template
</label>
<Textarea
value={soulTemplate}
onChange={(event) => setSoulTemplate(event.target.value)}
rows={10}
disabled={isLoading}
/>
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{errorMessage}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating…" : "Create agent"}
</Button>
<Button
variant="outline"
type="button"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Schedule & notifications
</p>
<div className="mt-4 grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Interval
</label>
<Input
value={heartbeatEvery}
onChange={(event) =>
setHeartbeatEvery(event.target.value)
}
placeholder="e.g. 10m"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target
</label>
<SearchableSelect
ariaLabel="Select heartbeat target"
value={heartbeatTarget}
onValueChange={setHeartbeatTarget}
options={HEARTBEAT_TARGET_OPTIONS}
placeholder="Select target"
searchPlaceholder="Search targets..."
emptyMessage="No matching targets."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
</div>
</div>
</div>
{errorMessage ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
{errorMessage}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating…" : "Create agent"}
</Button>
<Button
variant="outline"
type="button"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
</div>
</form>
</form>
)}
</div>
</main>
</SignedIn>

View File

@@ -42,6 +42,10 @@ import {
getListBoardsApiV1BoardsGetQueryKey,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { AgentRead } from "@/api/generated/model";
const parseTimestamp = (value?: string | null) => {
@@ -88,6 +92,20 @@ export default function AgentsPage() {
const queryClient = useQueryClient();
const router = useRouter();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
@@ -102,7 +120,7 @@ export default function AgentsPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000,
refetchOnMount: "always",
},
@@ -113,7 +131,7 @@ export default function AgentsPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 15_000,
refetchOnMount: "always",
},
@@ -323,97 +341,105 @@ export default function AgentsPage() {
</div>
<div className="p-8">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{agentsQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No agents yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first agent to start executing tasks
on this board.
</p>
<Link
href="/agents/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first agent
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can access agents.
</div>
</div>
) : (
<>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{agentsQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No agents yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first agent to start executing
tasks on this board.
</p>
<Link
href="/agents/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first agent
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{agentsQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{agentsQuery.error.message}
</p>
) : null}
{agentsQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{agentsQuery.error.message}
</p>
) : null}
</>
)}
</div>
</main>
</SignedIn>

View File

@@ -27,9 +27,14 @@ import {
streamBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryStreamGet,
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet,
} from "@/api/generated/board-group-memory/board-group-memory";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type {
BoardGroupHeartbeatApplyResult,
BoardGroupMemoryRead,
OrganizationMemberRead,
} from "@/api/generated/model";
import type { BoardGroupBoardSnapshot } from "@/api/generated/model";
import { Markdown } from "@/components/atoms/Markdown";
@@ -96,6 +101,18 @@ const priorityTone = (value?: string | null) => {
const safeCount = (snapshot: BoardGroupBoardSnapshot, key: string) =>
snapshot.task_counts?.[key] ?? 0;
const canWriteGroupBoards = (
member: OrganizationMemberRead | null,
boardIds: Set<string>,
) => {
if (!member) return false;
if (member.all_boards_write) return true;
if (!member.board_access || boardIds.size === 0) return false;
return member.board_access.some(
(access) => access.can_write && boardIds.has(access.board_id),
);
};
function GroupChatMessageCard({ message }: { message: BoardGroupMemoryRead }) {
return (
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4">
@@ -215,6 +232,34 @@ export default function BoardGroupDetailPage() {
snapshotQuery.data?.status === 200 ? snapshotQuery.data.data : null;
const group = snapshot?.group ?? null;
const boards = useMemo(() => snapshot?.boards ?? [], [snapshot?.boards]);
const boardIdSet = useMemo(() => {
const ids = new Set<string>();
boards.forEach((item) => {
if (item.board?.id) {
ids.add(item.board.id);
}
});
return ids;
}, [boards]);
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member?.role === "admin" || member?.role === "owner";
const canWriteGroup = useMemo(
() => canWriteGroupBoards(member, boardIdSet),
[boardIdSet, member],
);
const canManageHeartbeat = Boolean(isAdmin && canWriteGroup);
const chatHistoryQuery =
useListBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryGet<
@@ -554,6 +599,10 @@ export default function BoardGroupDetailPage() {
setChatError("Sign in to send messages.");
return false;
}
if (!canWriteGroup) {
setChatError("Read-only access. You cannot post group messages.");
return false;
}
const trimmed = content.trim();
if (!trimmed) return false;
@@ -583,7 +632,7 @@ export default function BoardGroupDetailPage() {
setIsChatSending(false);
}
},
[chatBroadcast, groupId, isSignedIn, mergeChatMessages],
[canWriteGroup, chatBroadcast, groupId, isSignedIn, mergeChatMessages],
);
const sendGroupNote = useCallback(
@@ -592,6 +641,10 @@ export default function BoardGroupDetailPage() {
setNoteSendError("Sign in to post.");
return false;
}
if (!canWriteGroup) {
setNoteSendError("Read-only access. You cannot post notes.");
return false;
}
const trimmed = content.trim();
if (!trimmed) return false;
@@ -621,7 +674,7 @@ export default function BoardGroupDetailPage() {
setIsNoteSending(false);
}
},
[groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
[canWriteGroup, groupId, isSignedIn, mergeNotesMessages, notesBroadcast],
);
const applyHeartbeat = useCallback(async () => {
@@ -629,6 +682,10 @@ export default function BoardGroupDetailPage() {
setHeartbeatApplyError("Sign in to apply.");
return;
}
if (!canManageHeartbeat) {
setHeartbeatApplyError("Read-only access. You cannot change agent pace.");
return;
}
const trimmed = heartbeatEvery.trim();
if (!trimmed) {
setHeartbeatApplyError("Heartbeat cadence is required.");
@@ -653,7 +710,13 @@ export default function BoardGroupDetailPage() {
} finally {
setIsHeartbeatApplying(false);
}
}, [groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
}, [
canManageHeartbeat,
groupId,
heartbeatEvery,
includeBoardLeads,
isSignedIn,
]);
return (
<DashboardShell>
@@ -793,7 +856,10 @@ export default function BoardGroupDetailPage() {
heartbeatEvery === value
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
!canManageHeartbeat &&
"opacity-50 cursor-not-allowed",
)}
disabled={!canManageHeartbeat}
onClick={() => {
setHeartbeatAmount(String(preset.amount));
setHeartbeatUnit(preset.unit);
@@ -812,19 +878,25 @@ export default function BoardGroupDetailPage() {
heartbeatEvery
? "border-slate-200"
: "border-rose-300 focus:border-rose-400 focus:ring-2 focus:ring-rose-100",
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
)}
placeholder="10"
inputMode="numeric"
type="number"
min={1}
step={1}
disabled={!canManageHeartbeat}
/>
<select
value={heartbeatUnit}
onChange={(event) =>
setHeartbeatUnit(event.target.value as HeartbeatUnit)
}
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm"
className={cn(
"h-8 rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-900 shadow-sm",
!canManageHeartbeat && "opacity-60 cursor-not-allowed",
)}
disabled={!canManageHeartbeat}
>
<option value="s">sec</option>
<option value="m">min</option>
@@ -839,6 +911,7 @@ export default function BoardGroupDetailPage() {
onChange={(event) =>
setIncludeBoardLeads(event.target.checked)
}
disabled={!canManageHeartbeat}
/>
Include leads
</label>
@@ -846,11 +919,26 @@ export default function BoardGroupDetailPage() {
size="sm"
variant="outline"
onClick={() => void applyHeartbeat()}
disabled={isHeartbeatApplying || !heartbeatEvery}
disabled={
isHeartbeatApplying ||
!heartbeatEvery ||
!canManageHeartbeat
}
title={
canManageHeartbeat
? "Apply heartbeat"
: "Read-only access"
}
>
{isHeartbeatApplying ? "Applying…" : "Apply"}
</Button>
</div>
{!canManageHeartbeat ? (
<p className="text-xs text-slate-500">
Read-only access. You cannot change agent pace for this
group.
</p>
) : null}
</div>
</div>
</div>
@@ -1035,6 +1123,7 @@ export default function BoardGroupDetailPage() {
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={chatBroadcast}
onChange={(event) => setChatBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/>
Broadcast
</label>
@@ -1072,9 +1161,14 @@ export default function BoardGroupDetailPage() {
</div>
<BoardChatComposer
placeholder="Message the whole group. Tag @lead, @name, or @all."
placeholder={
canWriteGroup
? "Message the whole group. Tag @lead, @name, or @all."
: "Read-only access. Group chat is disabled."
}
isSending={isChatSending}
onSend={sendGroupChat}
disabled={!canWriteGroup}
/>
</div>
</div>
@@ -1115,6 +1209,7 @@ export default function BoardGroupDetailPage() {
className="h-4 w-4 rounded border-slate-300 text-blue-600"
checked={notesBroadcast}
onChange={(event) => setNotesBroadcast(event.target.checked)}
disabled={!canWriteGroup}
/>
Broadcast
</label>
@@ -1152,9 +1247,14 @@ export default function BoardGroupDetailPage() {
</div>
<BoardChatComposer
placeholder="Post a shared note for all linked boards. Tag @lead, @name, or @all."
placeholder={
canWriteGroup
? "Post a shared note for all linked boards. Tag @lead, @name, or @all."
: "Read-only access. Notes are disabled."
}
isSending={isNoteSending}
onSend={sendGroupNote}
disabled={!canWriteGroup}
/>
</div>
</div>

View File

@@ -22,6 +22,10 @@ import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type {
BoardGroupRead,
BoardRead,
@@ -59,6 +63,20 @@ export default function EditBoardPage() {
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const mainRef = useRef<HTMLElement | null>(null);
const [board, setBoard] = useState<BoardRead | null>(null);
@@ -130,7 +148,7 @@ export default function EditBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -141,7 +159,7 @@ export default function EditBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -152,7 +170,7 @@ export default function EditBoardPage() {
ApiError
>(boardId ?? "", {
query: {
enabled: Boolean(isSignedIn && boardId),
enabled: Boolean(isSignedIn && isAdmin && boardId),
refetchOnMount: "always",
retry: false,
},
@@ -318,183 +336,191 @@ export default function EditBoardPage() {
</div>
<div className="p-8">
<div className="space-y-6">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
{resolvedBoardType !== "general" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success
metrics.
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can edit board settings.
</div>
) : (
<div className="space-y-6">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
{resolvedBoardType !== "general" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success
metrics.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Start onboarding
</Button>
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select
value={resolvedBoardType}
onValueChange={setBoardType}
>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={resolvedBoardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
searchPlaceholder="Search groups..."
emptyMessage="No groups found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Boards in the same group can share cross-board context
for agents.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Start onboarding
</Button>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) =>
setTargetDate(event.target.value)
}
disabled={isLoading}
/>
</div>
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
Objective
</label>
<Select
value={resolvedBoardType}
onValueChange={setBoardType}
>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
Success metrics (JSON)
</label>
<SearchableSelect
ariaLabel="Select board group"
value={resolvedBoardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
searchPlaceholder="Search groups..."
emptyMessage="No groups found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) =>
setSuccessMetrics(event.target.value)
}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Boards in the same group can share cross-board context
for agents.
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) =>
setSuccessMetrics(event.target.value)
}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>
No gateways available. Create one in Gateways to
continue.
</p>
</div>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>
No gateways available. Create one in Gateways to
continue.
</p>
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || !baseBoard || !isFormReady}
>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading || !baseBoard || !isFormReady}
>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</form>
</div>
)}
</div>
</main>
</SignedIn>

View File

@@ -45,6 +45,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError } from "@/api/mutator";
import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
import {
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
@@ -62,6 +63,10 @@ import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
} from "@/api/generated/board-memory/board-memory";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import {
createTaskApiV1BoardsBoardIdTasksPost,
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
@@ -76,6 +81,7 @@ import type {
BoardGroupSnapshot,
BoardMemoryRead,
BoardRead,
OrganizationMemberRead,
TaskCardRead,
TaskCommentRead,
TaskRead,
@@ -168,6 +174,49 @@ const formatShortTimestamp = (value: string) => {
});
};
type ToastMessage = {
id: number;
message: string;
tone: "error" | "success";
};
const formatActionError = (err: unknown, fallback: string) => {
if (err instanceof ApiError) {
if (err.status === 403) {
return "Read-only access. You do not have permission to make changes.";
}
return err.message || fallback;
}
if (err instanceof Error && err.message) {
return err.message;
}
return fallback;
};
const resolveBoardAccess = (
member: OrganizationMemberRead | null,
boardId?: string | null,
) => {
if (!member || !boardId) {
return { canRead: false, canWrite: false };
}
if (member.all_boards_write) {
return { canRead: true, canWrite: true };
}
if (member.all_boards_read) {
return { canRead: true, canWrite: false };
}
const entry = member.board_access?.find(
(access) => access.board_id === boardId,
);
if (!entry) {
return { canRead: false, canWrite: false };
}
const canWrite = Boolean(entry.can_write);
const canRead = Boolean(entry.can_read || entry.can_write);
return { canRead, canWrite };
};
const TaskCommentCard = memo(function TaskCommentCard({
comment,
authorLabel,
@@ -322,6 +371,31 @@ export default function BoardDetailPage() {
const isPageActive = usePageActive();
const taskIdFromUrl = searchParams.get("taskId");
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
},
});
const boardAccess = useMemo(
() =>
resolveBoardAccess(
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null,
boardId,
),
[membershipQuery.data, boardId],
);
const isOrgAdmin = useMemo(() => {
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
return member ? ["owner", "admin"].includes(member.role) : false;
}, [membershipQuery.data]);
const canWrite = boardAccess.canWrite;
const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
@@ -387,7 +461,10 @@ export default function BoardDetailPage() {
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board");
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const isLiveFeedOpenRef = useRef(false);
const toastIdRef = useRef(0);
const toastTimersRef = useRef<Record<number, number>>({});
const pushLiveFeed = useCallback((comment: TaskComment) => {
const alreadySeen = liveFeedRef.current.some(
(item) => item.id === comment.id,
@@ -423,6 +500,31 @@ export default function BoardDetailPage() {
}, 2200);
}, []);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const timer = toastTimersRef.current[id];
if (timer !== undefined) {
window.clearTimeout(timer);
delete toastTimersRef.current[id];
}
}, []);
const pushToast = useCallback(
(message: string, tone: ToastMessage["tone"] = "error") => {
const trimmed = message.trim();
if (!trimmed) return;
const id = toastIdRef.current + 1;
toastIdRef.current = id;
setToasts((prev) => [...prev, { id, message: trimmed, tone }]);
if (typeof window !== "undefined") {
toastTimersRef.current[id] = window.setTimeout(() => {
dismissToast(id);
}, 3500);
}
},
[dismissToast],
);
useEffect(() => {
liveFeedHistoryLoadedRef.current = false;
setIsLiveFeedHistoryLoading(false);
@@ -448,6 +550,17 @@ export default function BoardDetailPage() {
};
}, []);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
Object.values(toastTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
toastTimersRef.current = {};
};
}, []);
useEffect(() => {
if (!isLiveFeedOpen) return;
if (!isSignedIn || !boardId) return;
@@ -1269,7 +1382,7 @@ export default function BoardDetailPage() {
useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId) return;
if (!isSignedIn || !boardId || !isOrgAdmin) return;
let isCancelled = false;
const abortController = new AbortController();
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
@@ -1372,7 +1485,7 @@ export default function BoardDetailPage() {
window.clearTimeout(reconnectTimeout);
}
};
}, [board, boardId, isPageActive, isSignedIn]);
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn]);
const resetForm = () => {
setTitle("");
@@ -1411,9 +1524,9 @@ export default function BoardDetailPage() {
setIsDialogOpen(false);
resetForm();
} catch (err) {
setCreateError(
err instanceof Error ? err.message : "Something went wrong.",
);
const message = formatActionError(err, "Something went wrong.");
setCreateError(message);
pushToast(message);
} finally {
setIsCreating(false);
}
@@ -1454,8 +1567,7 @@ export default function BoardDetailPage() {
}
return { ok: true, error: null };
} catch (err) {
const message =
err instanceof Error ? err.message : "Unable to send message.";
const message = formatActionError(err, "Unable to send message.");
return { ok: false, error: message };
}
},
@@ -1473,6 +1585,7 @@ export default function BoardDetailPage() {
if (!result.ok) {
if (result.error) {
setChatError(result.error);
pushToast(result.error);
}
return false;
}
@@ -1481,7 +1594,7 @@ export default function BoardDetailPage() {
setIsChatSending(false);
}
},
[postBoardChatMessage],
[postBoardChatMessage, pushToast],
);
const openAgentsControlDialog = (action: "pause" | "resume") => {
@@ -1497,16 +1610,16 @@ export default function BoardDetailPage() {
try {
const result = await postBoardChatMessage(command);
if (!result.ok) {
setAgentsControlError(
result.error ?? `Unable to send ${command} command.`,
);
const message = result.error ?? `Unable to send ${command} command.`;
setAgentsControlError(message);
pushToast(message);
return;
}
setIsAgentsControlDialogOpen(false);
} finally {
setIsAgentsControlSending(false);
}
}, [agentsControlAction, postBoardChatMessage]);
}, [agentsControlAction, postBoardChatMessage, pushToast]);
const assigneeById = useMemo(() => {
const map = new Map<string, string>();
@@ -1746,9 +1859,9 @@ export default function BoardDetailPage() {
setComments((prev) => [created, ...prev]);
setNewComment("");
} catch (err) {
setPostCommentError(
err instanceof Error ? err.message : "Unable to send message.",
);
const message = formatActionError(err, "Unable to send message.");
setPostCommentError(message);
pushToast(message);
} finally {
setIsPostingComment(false);
taskCommentInputRef.current?.focus();
@@ -1830,9 +1943,9 @@ export default function BoardDetailPage() {
setIsEditDialogOpen(false);
}
} catch (err) {
setSaveTaskError(
err instanceof Error ? err.message : "Something went wrong.",
);
const message = formatActionError(err, "Something went wrong.");
setSaveTaskError(message);
pushToast(message);
} finally {
setIsSavingTask(false);
}
@@ -1863,9 +1976,9 @@ export default function BoardDetailPage() {
setIsDeleteDialogOpen(false);
closeComments();
} catch (err) {
setDeleteTaskError(
err instanceof Error ? err.message : "Something went wrong.",
);
const message = formatActionError(err, "Something went wrong.");
setDeleteTaskError(message);
pushToast(message);
} finally {
setIsDeletingTask(false);
}
@@ -1936,10 +2049,12 @@ export default function BoardDetailPage() {
);
} catch (err) {
setTasks(previousTasks);
setError(err instanceof Error ? err.message : "Unable to move task.");
const message = formatActionError(err, "Unable to move task.");
setError(message);
pushToast(message);
}
},
[boardId, isSignedIn, taskTitleById],
[boardId, isSignedIn, pushToast, taskTitleById],
);
const agentInitials = (agent: Agent) =>
@@ -2085,6 +2200,12 @@ export default function BoardDetailPage() {
const handleApprovalDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => {
if (!isSignedIn || !boardId) return;
if (!canWrite) {
pushToast(
"Read-only access. You do not have permission to update approvals.",
);
return;
}
setApprovalsUpdatingId(approvalId);
setApprovalsError(null);
try {
@@ -2102,14 +2223,14 @@ export default function BoardDetailPage() {
prev.map((item) => (item.id === approvalId ? updated : item)),
);
} catch (err) {
setApprovalsError(
err instanceof Error ? err.message : "Unable to update approval.",
);
const message = formatActionError(err, "Unable to update approval.");
setApprovalsError(message);
pushToast(message);
} finally {
setApprovalsUpdatingId(null);
}
},
[boardId, isSignedIn],
[boardId, canWrite, isSignedIn, pushToast],
);
return (
@@ -2174,7 +2295,8 @@ export default function BoardDetailPage() {
onClick={() => setIsDialogOpen(true)}
className="h-9 w-9 p-0"
aria-label="New task"
title="New task"
title={canWrite ? "New task" : "Read-only access"}
disabled={!canWrite}
>
<Plus className="h-4 w-4" />
</Button>
@@ -2192,31 +2314,44 @@ export default function BoardDetailPage() {
</span>
) : null}
</Button>
<Button
variant="outline"
onClick={() =>
openAgentsControlDialog(
isAgentsPaused ? "resume" : "pause",
)
}
disabled={!isSignedIn || !boardId || isAgentsControlSending}
className={cn(
"h-9 w-9 p-0",
isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "",
)}
aria-label={
isAgentsPaused ? "Resume agents" : "Pause agents"
}
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
>
{isAgentsPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
{isOrgAdmin ? (
<Button
variant="outline"
onClick={() =>
openAgentsControlDialog(
isAgentsPaused ? "resume" : "pause",
)
}
disabled={
!isSignedIn ||
!boardId ||
isAgentsControlSending ||
!canWrite
}
className={cn(
"h-9 w-9 p-0",
isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "",
)}
aria-label={
isAgentsPaused ? "Resume agents" : "Pause agents"
}
title={
canWrite
? isAgentsPaused
? "Resume agents"
: "Pause agents"
: "Read-only access"
}
>
{isAgentsPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
) : null}
<Button
variant="outline"
onClick={openBoardChat}
@@ -2235,83 +2370,87 @@ export default function BoardDetailPage() {
>
<Activity className="h-4 w-4" />
</Button>
<button
type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
aria-label="Board settings"
title="Board settings"
>
<Settings className="h-4 w-4" />
</button>
{isOrgAdmin ? (
<button
type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
aria-label="Board settings"
title="Board settings"
>
<Settings className="h-4 w-4" />
</button>
) : null}
</div>
</div>
</div>
</div>
<div className="relative flex gap-6 p-6">
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Agents
</p>
<p className="text-xs text-slate-400">
{sortedAgents.length} total
</p>
</div>
<button
type="button"
onClick={() => router.push("/agents/new")}
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
>
Add
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{sortedAgents.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
No agents assigned yet.
{isOrgAdmin ? (
<aside className="flex h-full w-64 flex-col rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Agents
</p>
<p className="text-xs text-slate-400">
{sortedAgents.length} total
</p>
</div>
) : (
sortedAgents.map((agent) => {
const isWorking = workingAgentIds.has(agent.id);
return (
<button
key={agent.id}
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
)}
onClick={() => router.push(`/agents/${agent.id}`)}
>
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{agentAvatarLabel(agent)}
<span
className={cn(
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
isWorking
? "bg-emerald-500"
: agent.status === "online"
? "bg-green-500"
: "bg-slate-300",
)}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{agent.name}
</p>
<p className="text-[11px] text-slate-500">
{agentRoleLabel(agent)}
</p>
</div>
</button>
);
})
)}
</div>
</aside>
<button
type="button"
onClick={() => router.push("/agents/new")}
className="rounded-md border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
>
Add
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{sortedAgents.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
No agents assigned yet.
</div>
) : (
sortedAgents.map((agent) => {
const isWorking = workingAgentIds.has(agent.id);
return (
<button
key={agent.id}
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-lg border border-transparent px-2 py-2 text-left transition hover:border-slate-200 hover:bg-slate-50",
)}
onClick={() => router.push(`/agents/${agent.id}`)}
>
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{agentAvatarLabel(agent)}
<span
className={cn(
"absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-white",
isWorking
? "bg-emerald-500"
: agent.status === "online"
? "bg-green-500"
: "bg-slate-300",
)}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{agent.name}
</p>
<p className="text-[11px] text-slate-500">
{agentRoleLabel(agent)}
</p>
</div>
</button>
);
})
)}
</div>
</aside>
) : null}
<div className="min-w-0 flex-1 space-y-6">
{error && (
@@ -2364,16 +2503,18 @@ export default function BoardDetailPage() {
>
View group
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Settings
</Button>
{isOrgAdmin ? (
<Button
variant="ghost"
size="sm"
onClick={() =>
router.push(`/boards/${boardId}/edit`)
}
disabled={!boardId}
>
Settings
</Button>
) : null}
</div>
</div>
</div>
@@ -2528,7 +2669,8 @@ export default function BoardDetailPage() {
<TaskBoard
tasks={tasks}
onTaskSelect={openComments}
onTaskMove={handleTaskMove}
onTaskMove={canWrite ? handleTaskMove : undefined}
readOnly={!canWrite}
/>
) : (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -2546,7 +2688,8 @@ export default function BoardDetailPage() {
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isCreating}
disabled={isCreating || !canWrite}
title={canWrite ? "New task" : "Read-only access"}
>
New task
</Button>
@@ -2660,7 +2803,8 @@ export default function BoardDetailPage() {
type="button"
onClick={() => setIsEditDialogOpen(true)}
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
disabled={!selectedTask}
disabled={!selectedTask || !canWrite}
title={canWrite ? "Edit task" : "Read-only access"}
>
<Pencil className="h-4 w-4" />
</button>
@@ -2826,7 +2970,10 @@ export default function BoardDetailPage() {
onClick={() =>
handleApprovalDecision(approval.id, "approved")
}
disabled={approvalsUpdatingId === approval.id}
disabled={
approvalsUpdatingId === approval.id || !canWrite
}
title={canWrite ? "Approve" : "Read-only access"}
>
Approve
</Button>
@@ -2836,7 +2983,10 @@ export default function BoardDetailPage() {
onClick={() =>
handleApprovalDecision(approval.id, "rejected")
}
disabled={approvalsUpdatingId === approval.id}
disabled={
approvalsUpdatingId === approval.id || !canWrite
}
title={canWrite ? "Reject" : "Read-only access"}
className="border-slate-300 text-slate-700"
>
Reject
@@ -2861,22 +3011,36 @@ export default function BoardDetailPage() {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
if (!canWrite) return;
event.preventDefault();
if (isPostingComment) return;
if (!newComment.trim()) return;
void handlePostComment();
}}
placeholder="Write a message for the assigned agent…"
placeholder={
canWrite
? "Write a message for the assigned agent…"
: "Read-only access. Comments are disabled."
}
className="min-h-[80px] bg-white"
disabled={!canWrite || isPostingComment}
/>
{postCommentError ? (
<p className="text-xs text-rose-600">{postCommentError}</p>
) : null}
{!canWrite ? (
<p className="text-xs text-slate-500">
Read-only access. You cannot post comments on this board.
</p>
) : null}
<div className="flex justify-end">
<Button
size="sm"
onClick={handlePostComment}
disabled={isPostingComment || !newComment.trim()}
disabled={
!canWrite || isPostingComment || !newComment.trim()
}
title={canWrite ? "Send message" : "Read-only access"}
>
{isPostingComment ? "Sending…" : "Send message"}
</Button>
@@ -2956,6 +3120,12 @@ export default function BoardDetailPage() {
<BoardChatComposer
isSending={isChatSending}
onSend={handleSendChat}
disabled={!canWrite}
placeholder={
canWrite
? "Message the board lead. Tag agents with @name."
: "Read-only access. Chat is disabled."
}
/>
</div>
</div>
@@ -3052,7 +3222,7 @@ export default function BoardDetailPage() {
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
placeholder="Task title"
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
<div className="space-y-2">
@@ -3064,7 +3234,7 @@ export default function BoardDetailPage() {
onChange={(event) => setEditDescription(event.target.value)}
placeholder="Task details"
className="min-h-[140px]"
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -3075,7 +3245,7 @@ export default function BoardDetailPage() {
<Select
value={editStatus}
onValueChange={(value) => setEditStatus(value as TaskStatus)}
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
@@ -3096,7 +3266,7 @@ export default function BoardDetailPage() {
<Select
value={editPriority}
onValueChange={setEditPriority}
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
@@ -3120,7 +3290,7 @@ export default function BoardDetailPage() {
onValueChange={(value) =>
setEditAssigneeId(value === "unassigned" ? "" : value)
}
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
@@ -3155,7 +3325,8 @@ export default function BoardDetailPage() {
disabled={
!selectedTask ||
isSavingTask ||
selectedTask.status === "done"
selectedTask.status === "done" ||
!canWrite
}
emptyMessage="No other tasks found."
/>
@@ -3195,8 +3366,14 @@ export default function BoardDetailPage() {
<button
type="button"
onClick={() => removeTaskDependency(depId)}
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700"
className={cn(
"rounded-full p-0.5 text-slate-500 transition",
canWrite
? "hover:bg-white hover:text-slate-700"
: "opacity-50 cursor-not-allowed",
)}
aria-label="Remove dependency"
disabled={!canWrite}
>
<X className="h-3 w-3" />
</button>
@@ -3217,21 +3394,26 @@ export default function BoardDetailPage() {
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={!selectedTask || isSavingTask}
disabled={!selectedTask || isSavingTask || !canWrite}
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
title={canWrite ? "Delete task" : "Read-only access"}
>
Delete task
</Button>
<Button
variant="outline"
onClick={handleTaskReset}
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
>
Reset
</Button>
<Button
onClick={() => handleTaskSave(true)}
disabled={!selectedTask || isSavingTask || !hasTaskChanges}
disabled={
!selectedTask || isSavingTask || !hasTaskChanges || !canWrite
}
>
{isSavingTask ? "Saving…" : "Save changes"}
</Button>
@@ -3262,7 +3444,7 @@ export default function BoardDetailPage() {
</Button>
<Button
onClick={handleDeleteTask}
disabled={isDeletingTask}
disabled={isDeletingTask || !canWrite}
className="bg-rose-600 text-white hover:bg-rose-700"
>
{isDeletingTask ? "Deleting…" : "Delete task"}
@@ -3294,6 +3476,7 @@ export default function BoardDetailPage() {
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="e.g. Prepare launch notes"
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
@@ -3305,13 +3488,18 @@ export default function BoardDetailPage() {
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional details"
className="min-h-[120px]"
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Priority
</label>
<Select value={priority} onValueChange={setPriority}>
<Select
value={priority}
onValueChange={setPriority}
disabled={!canWrite || isCreating}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
@@ -3334,77 +3522,117 @@ export default function BoardDetailPage() {
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateTask} disabled={isCreating}>
<Button
onClick={handleCreateTask}
disabled={!canWrite || isCreating}
>
{isCreating ? "Creating…" : "Create task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isAgentsControlDialogOpen}
onOpenChange={(nextOpen) => {
setIsAgentsControlDialogOpen(nextOpen);
if (!nextOpen) {
setAgentsControlError(null);
}
}}
>
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</DialogTitle>
<DialogDescription>
{agentsControlAction === "pause"
? "Send /pause to every agent on this board."
: "Send /resume to every agent on this board."}
</DialogDescription>
</DialogHeader>
{agentsControlError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{agentsControlError}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-semibold text-slate-900">What happens</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
This posts{" "}
<span className="font-mono">
{agentsControlAction === "pause" ? "/pause" : "/resume"}
</span>{" "}
to board chat.
</li>
<li>Mission Control forwards it to all agents on this board.</li>
</ul>
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsAgentsControlDialogOpen(false)}
disabled={isAgentsControlSending}
>
Cancel
</Button>
<Button
onClick={handleConfirmAgentsControl}
disabled={isAgentsControlSending}
>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"
{isOrgAdmin ? (
<Dialog
open={isAgentsControlDialogOpen}
onOpenChange={(nextOpen) => {
setIsAgentsControlDialogOpen(nextOpen);
if (!nextOpen) {
setAgentsControlError(null);
}
}}
>
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogTitle>
<DialogDescription>
{agentsControlAction === "pause"
? "Send /pause to every agent on this board."
: "Send /resume to every agent on this board."}
</DialogDescription>
</DialogHeader>
{agentsControlError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{agentsControlError}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-semibold text-slate-900">What happens</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
This posts{" "}
<span className="font-mono">
{agentsControlAction === "pause" ? "/pause" : "/resume"}
</span>{" "}
to board chat.
</li>
<li>
Mission Control forwards it to all agents on this board.
</li>
</ul>
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsAgentsControlDialogOpen(false)}
disabled={isAgentsControlSending}
>
Cancel
</Button>
<Button
onClick={handleConfirmAgentsControl}
disabled={isAgentsControlSending}
>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
{toasts.length ? (
<div className="fixed bottom-6 right-6 z-[60] flex w-[320px] max-w-[90vw] flex-col gap-3">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"rounded-xl border bg-white px-4 py-3 text-sm shadow-lush",
toast.tone === "error"
? "border-rose-200 text-rose-700"
: "border-emerald-200 text-emerald-700",
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1 h-2 w-2 rounded-full",
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
)}
/>
<p className="flex-1 text-sm text-slate-700">{toast.message}</p>
<button
type="button"
className="text-xs text-slate-400 hover:text-slate-600"
onClick={() => dismissToast(toast.id)}
>
Dismiss
</button>
</div>
</div>
))}
</div>
) : null}
{/* onboarding moved to board settings */}
</DashboardShell>

View File

@@ -18,6 +18,10 @@ import {
type listGatewaysApiV1GatewaysGetResponse,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardGroupRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -36,6 +40,20 @@ export default function NewBoardPage() {
const router = useRouter();
const { isSignedIn } = useAuth();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState("");
const [gatewayId, setGatewayId] = useState<string>("");
const [boardGroupId, setBoardGroupId] = useState<string>("none");
@@ -47,7 +65,7 @@ export default function NewBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -58,7 +76,7 @@ export default function NewBoardPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
retry: false,
},
@@ -166,100 +184,106 @@ export default function NewBoardPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-4">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Release operations"
disabled={isLoading}
/>
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can create boards.
</div>
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-4">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Release operations"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={boardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
searchPlaceholder="Search groups..."
emptyMessage="No groups found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Optional. Groups increase cross-board visibility.
</p>
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board group
</label>
<SearchableSelect
ariaLabel="Select board group"
value={boardGroupId}
onValueChange={setBoardGroupId}
options={groupOptions}
placeholder="No group"
searchPlaceholder="Search groups..."
emptyMessage="No groups found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Optional. Groups increase cross-board visibility.
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>
No gateways available. Create one in{" "}
<Link
href="/gateways"
className="font-medium text-blue-600 hover:text-blue-700"
>
Gateways
</Link>{" "}
to continue.
</p>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/boards")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !isFormReady}>
{isLoading ? "Creating…" : "Create board"}
</Button>
</div>
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>
No gateways available. Create one in{" "}
<Link
href="/gateways"
className="font-medium text-blue-600 hover:text-blue-700"
>
Gateways
</Link>{" "}
to continue.
</p>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/boards")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !isFormReady}>
{isLoading ? "Creating…" : "Create board"}
</Button>
</div>
</form>
</form>
)}
</div>
</main>
</SignedIn>

View File

@@ -25,6 +25,10 @@ import {
type listBoardGroupsApiV1BoardGroupsGetResponse,
useListBoardGroupsApiV1BoardGroupsGet,
} from "@/api/generated/board-groups/board-groups";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -56,6 +60,20 @@ const compactId = (value: string) =>
export default function BoardsPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
@@ -264,7 +282,7 @@ export default function BoardsPage() {
{boards.length === 1 ? "" : "s"} total.
</p>
</div>
{boards.length > 0 ? (
{boards.length > 0 && isAdmin ? (
<Link
href="/boards/new"
className={buttonVariants({

View File

@@ -15,6 +15,10 @@ import {
useGetGatewayApiV1GatewaysGatewayIdGet,
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { GatewayUpdate } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -50,6 +54,20 @@ export default function EditGatewayPage() {
? gatewayIdParam[0]
: gatewayIdParam;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState<string | undefined>(undefined);
const [gatewayUrl, setGatewayUrl] = useState<string | undefined>(undefined);
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
@@ -77,7 +95,7 @@ export default function EditGatewayPage() {
ApiError
>(gatewayId ?? "", {
query: {
enabled: Boolean(isSignedIn && gatewayId),
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchOnMount: "always",
retry: false,
},
@@ -230,137 +248,145 @@ export default function EditGatewayPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can edit gateways.
</div>
<div className="grid gap-6 md:grid-cols-2">
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span>
Gateway name <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
value={resolvedGatewayUrl}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={
gatewayUrlError ? "border-red-500" : undefined
}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={resolvedGatewayUrl}
value={resolvedGatewayToken}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
placeholder="Bearer token"
disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={resolvedGatewayToken}
onChange={(event) => {
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={resolvedMainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={resolvedMainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={resolvedWorkspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={resolvedWorkspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
/>
>
Back
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</div>
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
>
Back
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</form>
)}
</div>
</main>
</SignedIn>

View File

@@ -18,6 +18,10 @@ import {
type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -49,12 +53,26 @@ export default function GatewayDetailPage() {
? gatewayIdParam[0]
: gatewayIdParam;
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const gatewayQuery = useGetGatewayApiV1GatewaysGatewayIdGet<
getGatewayApiV1GatewaysGatewayIdGetResponse,
ApiError
>(gatewayId ?? "", {
query: {
enabled: Boolean(isSignedIn && gatewayId),
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchInterval: 30_000,
},
});
@@ -67,7 +85,7 @@ export default function GatewayDetailPage() {
ApiError
>(gatewayId ? { gateway_id: gatewayId } : undefined, {
query: {
enabled: Boolean(isSignedIn && gatewayId),
enabled: Boolean(isSignedIn && isAdmin && gatewayId),
refetchInterval: 15_000,
},
});
@@ -85,7 +103,7 @@ export default function GatewayDetailPage() {
ApiError
>(statusParams, {
query: {
enabled: Boolean(isSignedIn && statusParams),
enabled: Boolean(isSignedIn && isAdmin && statusParams),
refetchInterval: 15_000,
},
});
@@ -142,7 +160,7 @@ export default function GatewayDetailPage() {
>
Back to gateways
</Button>
{gatewayId ? (
{isAdmin && gatewayId ? (
<Button
onClick={() => router.push(`/gateways/${gatewayId}/edit`)}
>
@@ -154,7 +172,11 @@ export default function GatewayDetailPage() {
</div>
<div className="p-8">
{gatewayQuery.isLoading ? (
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can access gateways.
</div>
) : gatewayQuery.isLoading ? (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
Loading gateway
</div>

View File

@@ -13,6 +13,10 @@ import {
gatewaysStatusApiV1GatewaysStatusGet,
useCreateGatewayApiV1GatewaysPost,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -42,6 +46,20 @@ export default function NewGatewayPage() {
const { isSignedIn } = useAuth();
const router = useRouter();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [name, setName] = useState("");
const [gatewayUrl, setGatewayUrl] = useState("");
const [gatewayToken, setGatewayToken] = useState("");
@@ -191,135 +209,143 @@ export default function NewGatewayPage() {
</div>
<div className="p-8">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway name <span className="text-red-500">*</span>
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can create gateways.
</div>
<div className="grid gap-6 md:grid-cols-2">
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span>
Gateway name <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Primary gateway"
disabled={isLoading}
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway URL <span className="text-red-500">*</span>
</label>
<div className="relative">
<Input
value={gatewayUrl}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
disabled={isLoading}
className={
gatewayUrlError ? "border-red-500" : undefined
}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={gatewayUrl}
value={gatewayToken}
onChange={(event) => {
setGatewayUrl(event.target.value);
setGatewayUrlError(null);
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="ws://gateway:18789"
placeholder="Bearer token"
disabled={isLoading}
className={gatewayUrlError ? "border-red-500" : undefined}
/>
<button
type="button"
onClick={runGatewayCheck}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
aria-label="Check gateway connection"
>
{gatewayCheckStatus === "checking" ? (
<RefreshCcw className="h-4 w-4 animate-spin" />
) : gatewayCheckStatus === "success" ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : gatewayCheckStatus === "error" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</button>
</div>
{gatewayUrlError ? (
<p className="text-xs text-red-500">{gatewayUrlError}</p>
) : gatewayCheckMessage ? (
<p
className={
gatewayCheckStatus === "success"
? "text-xs text-emerald-600"
: "text-xs text-red-500"
}
>
{gatewayCheckMessage}
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway token
</label>
<Input
value={gatewayToken}
onChange={(event) => {
setGatewayToken(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
onBlur={runGatewayCheck}
placeholder="Bearer token"
disabled={isLoading}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={mainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Main session key <span className="text-red-500">*</span>
</label>
<Input
value={mainSessionKey}
onChange={(event) => {
setMainSessionKey(event.target.value);
setGatewayCheckStatus("idle");
setGatewayCheckMessage(null);
}}
placeholder={DEFAULT_MAIN_SESSION_KEY}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Workspace root <span className="text-red-500">*</span>
</label>
<Input
value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)}
placeholder={DEFAULT_WORKSPACE_ROOT}
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
/>
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Creating…" : "Create gateway"}
</Button>
</div>
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push("/gateways")}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !canSubmit}>
{isLoading ? "Creating…" : "Create gateway"}
</Button>
</div>
</form>
</form>
)}
</div>
</main>
</SignedIn>

View File

@@ -35,6 +35,10 @@ import {
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import type { GatewayRead } from "@/api/generated/model";
const truncate = (value?: string | null, max = 24) => {
@@ -58,6 +62,20 @@ const formatTimestamp = (value?: string | null) => {
export default function GatewaysPage() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
@@ -69,7 +87,7 @@ export default function GatewaysPage() {
ApiError
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && isAdmin),
refetchInterval: 30_000,
refetchOnMount: "always",
},
@@ -240,7 +258,7 @@ export default function GatewaysPage() {
Manage OpenClaw gateway connections used by boards
</p>
</div>
{gateways.length > 0 ? (
{isAdmin && gateways.length > 0 ? (
<Link
href="/gateways/new"
className={buttonVariants({
@@ -256,102 +274,110 @@ export default function GatewaysPage() {
</div>
<div className="p-8">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{gatewaysQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="2"
y="7"
width="20"
height="14"
rx="2"
ry="2"
/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No gateways yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first gateway to connect boards and
start managing your OpenClaw connections.
</p>
<Link
href="/gateways/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first gateway
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
{!isAdmin ? (
<div className="rounded-xl border border-slate-200 bg-white px-6 py-5 text-sm text-slate-600 shadow-sm">
Only organization owners and admins can access gateways.
</div>
</div>
) : (
<>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-6 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-100">
{gatewaysQuery.isLoading ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8">
<span className="text-sm text-slate-500">
Loading
</span>
</td>
</tr>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="2"
y="7"
width="20"
height="14"
rx="2"
ry="2"
/>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No gateways yet
</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">
Create your first gateway to connect boards
and start managing your OpenClaw connections.
</p>
<Link
href="/gateways/new"
className={buttonVariants({
size: "md",
variant: "primary",
})}
>
Create your first gateway
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{gatewaysQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{gatewaysQuery.error.message}
</p>
) : null}
{gatewaysQuery.error ? (
<p className="mt-4 text-sm text-red-500">
{gatewaysQuery.error.message}
</p>
) : null}
</>
)}
</div>
</main>
</SignedIn>

View File

@@ -0,0 +1,171 @@
"use client";
export const dynamic = "force-dynamic";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import { useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost } from "@/api/generated/organizations/organizations";
import { BrandMark } from "@/components/atoms/BrandMark";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
function InviteContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { isSignedIn } = useAuth();
const tokenFromQuery = (searchParams.get("token") ?? "").trim();
const [token, setToken] = useState(tokenFromQuery);
const [error, setError] = useState<string | null>(null);
const [accepted, setAccepted] = useState(false);
useEffect(() => {
setToken(tokenFromQuery);
}, [tokenFromQuery]);
const acceptInviteMutation =
useAcceptOrgInviteApiV1OrganizationsInvitesAcceptPost<ApiError>({
mutation: {
onSuccess: (result) => {
if (result.status === 200) {
setAccepted(true);
setError(null);
setTimeout(() => router.push("/organization"), 800);
}
},
onError: (err) => {
setError(err.message || "Unable to accept invite.");
},
},
});
const handleAccept = (event?: React.FormEvent) => {
event?.preventDefault();
if (!isSignedIn) return;
const trimmed = token.trim();
if (!trimmed) {
setError("Invite token is required.");
return;
}
setError(null);
acceptInviteMutation.mutate({ data: { token: trimmed } });
};
const isSubmitting = acceptInviteMutation.isPending;
const isReady = Boolean(token.trim());
const helperText = useMemo(() => {
if (accepted) {
return "Invite accepted. Redirecting to your organization…";
}
if (!token.trim()) {
return "Paste the invite token or open the invite link you were sent.";
}
return "Accept the invite to join the organization.";
}, [accepted, token]);
return (
<div className="min-h-screen bg-app text-strong">
<header className="border-b border-[color:var(--border)] bg-white">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<BrandMark />
</div>
</header>
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Organization Invite
</p>
<h1 className="text-2xl font-semibold text-strong">
Join your team in OpenClaw
</h1>
<p className="text-sm text-muted">{helperText}</p>
</div>
<div className="mt-6 flex flex-col gap-4">
<label className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Invite Token
</label>
<Input
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="Paste invite token"
disabled={accepted || isSubmitting}
/>
{error ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-600">
{error}
</div>
) : null}
<SignedOut>
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
<p>Sign in to accept your invite.</p>
<SignInButton mode="modal">
<Button size="md">Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<form
className="flex flex-wrap items-center gap-3"
onSubmit={handleAccept}
>
<Button
type="submit"
size="md"
disabled={!isReady || isSubmitting || accepted}
>
{accepted
? "Invite accepted"
: isSubmitting
? "Accepting…"
: "Accept invite"}
</Button>
<Button
type="button"
variant="ghost"
size="md"
onClick={() => router.push("/")}
disabled={isSubmitting}
>
Go back
</Button>
</form>
</SignedIn>
</div>
</div>
</main>
</div>
);
}
export default function InvitePage() {
return (
<Suspense
fallback={
<div className="min-h-screen bg-app text-strong">
<header className="border-b border-[color:var(--border)] bg-white">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<BrandMark />
</div>
</header>
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
<div className="text-sm text-muted">Loading invite</div>
</div>
</main>
</div>
}
>
<InviteContent />
</Suspense>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea";
type BoardChatComposerProps = {
placeholder?: string;
isSending?: boolean;
disabled?: boolean;
onSend: (content: string) => Promise<boolean>;
};
function BoardChatComposerImpl({
placeholder = "Message the board lead. Tag agents with @name.",
isSending = false,
disabled = false,
onSend,
}: BoardChatComposerProps) {
const [value, setValue] = useState("");
@@ -28,7 +30,7 @@ function BoardChatComposerImpl({
}, [isSending]);
const send = useCallback(async () => {
if (isSending) return;
if (isSending || disabled) return;
const trimmed = value.trim();
if (!trimmed) return;
const ok = await onSend(trimmed);
@@ -36,7 +38,7 @@ function BoardChatComposerImpl({
if (ok) {
setValue("");
}
}, [isSending, onSend, value]);
}, [disabled, isSending, onSend, value]);
return (
<div className="mt-4 space-y-2">
@@ -53,12 +55,12 @@ function BoardChatComposerImpl({
}}
placeholder={placeholder}
className="min-h-[120px]"
disabled={isSending}
disabled={isSending || disabled}
/>
<div className="flex justify-end">
<Button
onClick={() => void send()}
disabled={isSending || !value.trim()}
disabled={isSending || disabled || !value.trim()}
>
{isSending ? "Sending…" : "Send"}
</Button>

View File

@@ -93,8 +93,10 @@ export function TaskCard({
/>
) : null}
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-900">{title}</p>
<div className="min-w-0 space-y-2">
<p className="text-sm font-medium text-slate-900 line-clamp-2 break-words">
{title}
</p>
{isBlocked ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
@@ -114,7 +116,7 @@ export function TaskCard({
</div>
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex flex-shrink-0 flex-col items-end gap-2">
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",

View File

@@ -8,11 +8,17 @@ import {
Bot,
CheckCircle2,
Folder,
Building2,
LayoutGrid,
Network,
} from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
useGetMyMembershipApiV1OrganizationsMeMemberGet,
} from "@/api/generated/organizations/organizations";
import {
type healthzHealthzGetResponse,
useHealthzHealthzGet,
@@ -21,6 +27,20 @@ import { cn } from "@/lib/utils";
export function DashboardSidebar() {
const pathname = usePathname();
const { isSignedIn } = useAuth();
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const member =
membershipQuery.data?.status === 200 ? membershipQuery.data.data : null;
const isAdmin = member ? ["owner", "admin"].includes(member.role) : false;
const healthQuery = useHealthzHealthzGet<healthzHealthzGetResponse, ApiError>(
{
query: {
@@ -67,18 +87,20 @@ export function DashboardSidebar() {
<BarChart3 className="h-4 w-4" />
Dashboard
</Link>
<Link
href="/gateways"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/gateways")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Network className="h-4 w-4" />
Gateways
</Link>
{isAdmin ? (
<Link
href="/gateways"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/gateways")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Network className="h-4 w-4" />
Gateways
</Link>
) : null}
<Link
href="/board-groups"
className={cn(
@@ -103,6 +125,18 @@ export function DashboardSidebar() {
<LayoutGrid className="h-4 w-4" />
Boards
</Link>
<Link
href="/organization"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/organization")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Building2 className="h-4 w-4" />
Organization
</Link>
<Link
href="/approvals"
className={cn(
@@ -127,18 +161,20 @@ export function DashboardSidebar() {
<Activity className="h-4 w-4" />
Live feed
</Link>
<Link
href="/agents"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/agents")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Bot className="h-4 w-4" />
Agents
</Link>
{isAdmin ? (
<Link
href="/agents"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/agents")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100",
)}
>
<Bot className="h-4 w-4" />
Agents
</Link>
) : null}
</nav>
</div>
<div className="border-t border-slate-200 p-4">

View File

@@ -0,0 +1,239 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Building2, Plus } from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
type listMyOrganizationsApiV1OrganizationsMeListGetResponse,
useCreateOrganizationApiV1OrganizationsPost,
useListMyOrganizationsApiV1OrganizationsMeListGet,
useSetActiveOrgApiV1OrganizationsMeActivePatch,
} from "@/api/generated/organizations/organizations";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function OrgSwitcher() {
const { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [orgName, setOrgName] = useState("");
const [orgError, setOrgError] = useState<string | null>(null);
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
if (!("BroadcastChannel" in window)) return;
const channel = new BroadcastChannel("org-switch");
channelRef.current = channel;
return () => {
channel.close();
channelRef.current = null;
};
}, []);
const orgsQuery = useListMyOrganizationsApiV1OrganizationsMeListGet<
listMyOrganizationsApiV1OrganizationsMeListGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
retry: false,
},
});
const orgs = orgsQuery.data?.status === 200 ? orgsQuery.data.data : [];
const activeOrg = orgs.find((item) => item.is_active) ?? null;
const orgValue = activeOrg?.id ?? "personal";
const announceOrgSwitch = (orgId: string) => {
if (typeof window === "undefined") return;
const payload = JSON.stringify({ orgId, ts: Date.now() });
try {
window.localStorage.setItem("openclaw_org_switch", payload);
} catch {
// Ignore storage failures.
}
channelRef.current?.postMessage(payload);
};
const setActiveOrgMutation =
useSetActiveOrgApiV1OrganizationsMeActivePatch<ApiError>({
mutation: {
onSuccess: (_result, variables) => {
const orgId = variables?.data?.organization_id;
if (orgId) {
announceOrgSwitch(orgId);
}
window.location.reload();
},
onError: (err) => {
setOrgError(err.message || "Unable to switch organization.");
},
},
});
const createOrgMutation =
useCreateOrganizationApiV1OrganizationsPost<ApiError>({
mutation: {
onSuccess: () => {
setOrgName("");
setOrgError(null);
setCreateOpen(false);
queryClient.invalidateQueries({
queryKey: ["/api/v1/organizations/me/list"],
});
if (typeof window !== "undefined") {
announceOrgSwitch("new");
}
window.location.reload();
},
onError: (err) => {
setOrgError(err.message || "Unable to create organization.");
},
},
});
const handleOrgChange = (value: string) => {
if (value === "__create__") {
setOrgError(null);
setCreateOpen(true);
return;
}
if (!value || value === orgValue) {
return;
}
setActiveOrgMutation.mutate({
data: { organization_id: value },
});
};
const handleCreateOrg = () => {
const trimmed = orgName.trim();
if (!trimmed) {
setOrgError("Organization name is required.");
return;
}
createOrgMutation.mutate({
data: { name: trimmed },
});
};
if (!isSignedIn) {
return null;
}
return (
<div className="relative">
<Select value={orgValue} onValueChange={handleOrgChange}>
<SelectTrigger className="h-9 w-[220px] rounded-md border-slate-200 bg-white px-3 text-sm font-medium text-slate-900 shadow-none focus:ring-2 focus:ring-blue-500/30 focus:ring-offset-0">
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-slate-400" />
<SelectValue placeholder="Select organization" />
</span>
</SelectTrigger>
<SelectContent className="min-w-[220px] rounded-md border-slate-200 p-1 shadow-xl">
<div className="px-3 pb-2 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Org switcher
</div>
{orgs.length ? (
orgs.map((org) => (
<SelectItem
key={org.id}
value={org.id}
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700 data-[state=checked]:bg-slate-50 data-[state=checked]:text-slate-900 focus:bg-slate-100"
>
{org.name}
</SelectItem>
))
) : (
<SelectItem
value={orgValue}
className="rounded-md py-2 pl-7 pr-3 text-sm text-slate-700"
>
Organization
</SelectItem>
)}
<SelectSeparator className="my-2" />
<SelectItem
value="__create__"
className="rounded-md py-2 pl-3 pr-3 text-sm font-medium text-slate-600 hover:text-slate-900 focus:bg-slate-100 [&>span:first-child]:hidden"
>
<span className="flex items-center gap-2">
<Plus className="h-4 w-4 text-slate-400" />
Create new org
</span>
</SelectItem>
</SelectContent>
</Select>
{orgError && !createOpen ? (
<p className="absolute left-0 top-full mt-1 text-xs text-rose-500">
{orgError}
</p>
) : null}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent aria-label="Create organization">
<DialogHeader>
<DialogTitle>Create a new organization</DialogTitle>
<DialogDescription>
This will switch you to the new organization as soon as it is
created.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<label
htmlFor="org-name"
className="text-xs font-semibold uppercase tracking-wide text-muted"
>
Organization name
</label>
<Input
id="org-name"
placeholder="Acme Robotics"
value={orgName}
onChange={(event) => setOrgName(event.target.value)}
/>
{orgError ? (
<p className="text-sm text-rose-500">{orgError}</p>
) : null}
</div>
<DialogFooter className="mt-6">
<Button
type="button"
variant="ghost"
onClick={() => setCreateOpen(false)}
>
Cancel
</Button>
<Button
type="button"
onClick={handleCreateOrg}
disabled={createOrgMutation.isPending}
>
{createOrgMutation.isPending ? "Creating..." : "Create org"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -34,6 +34,7 @@ type TaskBoardProps = {
tasks: Task[];
onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise<void>;
readOnly?: boolean;
};
type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked";
@@ -99,6 +100,7 @@ export const TaskBoard = memo(function TaskBoard({
tasks,
onTaskSelect,
onTaskMove,
readOnly = false,
}: TaskBoardProps) {
const boardRef = useRef<HTMLDivElement | null>(null);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -268,6 +270,10 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) {
event.preventDefault();
return;
}
if (task.is_blocked) {
event.preventDefault();
return;
@@ -287,6 +293,7 @@ export const TaskBoard = memo(function TaskBoard({
const handleDrop =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault();
setActiveColumn(null);
const raw = event.dataTransfer.getData("text/plain");
@@ -303,6 +310,7 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragOver =
(status: TaskStatus) => (event: React.DragEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault();
if (activeColumn !== status) {
setActiveColumn(status);
@@ -310,6 +318,7 @@ export const TaskBoard = memo(function TaskBoard({
};
const handleDragLeave = (status: TaskStatus) => () => {
if (readOnly) return;
if (activeColumn === status) {
setActiveColumn(null);
}
@@ -368,11 +377,13 @@ export const TaskBoard = memo(function TaskBoard({
key={column.title}
className={cn(
"kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status && "ring-2 ring-slate-200",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={handleDrop(column.status)}
onDragOver={handleDragOver(column.status)}
onDragLeave={handleDragLeave(column.status)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : handleDragLeave(column.status)}
>
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
<div className="flex items-center justify-between">
@@ -445,10 +456,10 @@ export const TaskBoard = memo(function TaskBoard({
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!task.is_blocked}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
onDragStart={readOnly ? undefined : handleDragStart(task)}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
</div>
))}

View File

@@ -1,10 +1,12 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { SignedIn, useUser } from "@/auth/clerk";
import { BrandMark } from "@/components/atoms/BrandMark";
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
import { UserMenu } from "@/components/organisms/UserMenu";
export function DashboardShell({ children }: { children: ReactNode }) {
@@ -12,13 +14,46 @@ export function DashboardShell({ children }: { children: ReactNode }) {
const displayName =
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
useEffect(() => {
if (typeof window === "undefined") return;
const handleStorage = (event: StorageEvent) => {
if (event.key !== "openclaw_org_switch" || !event.newValue) return;
window.location.reload();
};
window.addEventListener("storage", handleStorage);
let channel: BroadcastChannel | null = null;
if ("BroadcastChannel" in window) {
channel = new BroadcastChannel("org-switch");
channel.onmessage = () => {
window.location.reload();
};
}
return () => {
window.removeEventListener("storage", handleStorage);
channel?.close();
};
}, []);
return (
<div className="min-h-screen bg-app text-strong">
<header className="sticky top-0 z-40 border-b border-slate-200 bg-white shadow-sm">
<div className="flex items-center justify-between px-6 py-3">
<BrandMark />
<div className="grid grid-cols-[260px_1fr_auto] items-center gap-0 py-3">
<div className="flex items-center px-6">
<BrandMark />
</div>
<SignedIn>
<div className="flex items-center gap-3">
<div className="flex items-center">
<div className="max-w-[220px]">
<OrgSwitcher />
</div>
</div>
</SignedIn>
<SignedIn>
<div className="flex items-center gap-3 px-6">
<div className="hidden text-right lg:block">
<p className="text-sm font-semibold text-slate-900">
{displayName}