feat(oss): enforce invitation account and owner billing flows

This commit is contained in:
dadachann
2026-07-26 16:04:33 +08:00
parent 602e10649b
commit 712f79ed77
18 changed files with 449 additions and 121 deletions
+22 -5
View File
@@ -68,6 +68,9 @@ function SpaceOAuthCallbackContent() {
'loading' | 'confirm' | 'success' | 'error'
>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
const [terminalErrorCode, setTerminalErrorCode] = useState<
'space_account_not_registered' | 'space_account_binding_required' | null
>(null);
const [isBindMode, setIsBindMode] = useState(false);
const [code, setCode] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
@@ -125,7 +128,15 @@ function SpaceOAuthCallbackContent() {
}
setStatus('error');
const errorObj = err as { msg?: string };
const errorObj = err as { code?: string; msg?: string };
if (
errorObj.code === 'space_account_not_registered' ||
errorObj.code === 'space_account_binding_required'
) {
setTerminalErrorCode(errorObj.code);
setErrorMessage(t(`account.${errorObj.code}`));
return;
}
const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch'));
@@ -168,7 +179,11 @@ function SpaceOAuthCallbackContent() {
}
setStatus('error');
const errorObj = err as { msg?: string };
const errorObj = err as { code?: string; msg?: string };
if (errorObj.code === 'space_account_email_mismatch') {
setErrorMessage(t('account.spaceEmailMismatch'));
return;
}
const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch'));
@@ -278,9 +293,11 @@ function SpaceOAuthCallbackContent() {
? t('account.bindSpaceSuccess')
: t('common.spaceLoginSuccess'))}
{status === 'error' &&
(isBindMode
? t('account.bindSpaceFailed')
: t('common.spaceLoginError'))}
(terminalErrorCode
? t(`account.${terminalErrorCode}Title`)
: isBindMode
? t('account.bindSpaceFailed')
: t('common.spaceLoginError'))}
</CardTitle>
<CardDescription>
{status === 'loading' &&
@@ -25,6 +25,7 @@ import {
import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
import { useCurrentWorkspace } from '@/app/infra/http';
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -89,8 +90,8 @@ export default function ModelsPanel({
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
const [spaceBilling, setSpaceBilling] =
useState<WorkspaceSpaceBilling | null>(null);
// Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
@@ -144,7 +145,7 @@ export default function ModelsPanel({
useEffect(() => {
if (active) {
loadUserInfo();
loadWorkspaceBilling();
loadProviders();
loadRequesterSupportTypes();
}
@@ -167,16 +168,11 @@ export default function ModelsPanel({
}
}, [providersLoaded, providers]);
async function loadUserInfo() {
async function loadWorkspaceBilling() {
try {
const userInfo = await httpClient.getUserInfo();
setAccountType(userInfo.account_type);
if (userInfo.account_type === 'space') {
const creditsInfo = await httpClient.getSpaceCredits();
setSpaceCredits(creditsInfo.credits);
}
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
} catch {
setAccountType('local');
setSpaceBilling(null);
}
}
@@ -546,8 +542,9 @@ export default function ModelsPanel({
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
accountType={accountType}
spaceCredits={spaceCredits}
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
@@ -44,7 +44,8 @@ interface ProviderCardProps {
isExpanded: boolean;
isLoading: boolean;
models?: ProviderModels;
accountType: 'local' | 'space';
isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null;
// Popover states
addModelPopoverOpen: string | null;
@@ -108,7 +109,8 @@ export default function ProviderCard({
isExpanded,
isLoading,
models,
accountType,
isWorkspaceOwner,
ownerSpaceBound,
spaceCredits,
addModelPopoverOpen,
editModelPopoverOpen,
@@ -198,7 +200,7 @@ export default function ProviderCard({
</div>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
{canManage && isLangBotModels && accountType !== 'space' && (
{isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
<Button
variant="outline"
size="sm"
@@ -208,32 +210,40 @@ export default function ProviderCard({
}}
>
<LogIn className="h-4 w-4 mr-1" />
{t('models.loginWithSpace')}
{t('models.ownerMustBindSpace')}
</Button>
)}
{isLangBotModels &&
accountType === 'space' &&
spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.stopPropagation();
window.open(
`${systemInfo.cloud_service_url}/profile?tab=billing`,
'_blank',
);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
<span className="text-xs text-muted-foreground">
{t('models.usesOwnerSpaceBilling')}
</span>
)}
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
<span className="text-xs text-muted-foreground">
{t('models.ownerMustBindSpace')}
</span>
)}
{canManage && !isLangBotModels && (
<>
<Button
+6
View File
@@ -35,6 +35,12 @@ export interface CurrentWorkspace {
plan_name?: string | null;
}
export interface WorkspaceSpaceBilling {
credits: number | null;
owner_space_bound: boolean;
is_workspace_owner: boolean;
}
/** Account-scoped Workspace entry returned before a Workspace is selected. */
export type WorkspaceBootstrapEntry = CurrentWorkspace;
+3 -4
View File
@@ -69,6 +69,7 @@ import type {
WorkspaceMembership,
WorkspaceBootstrapResponse,
WorkspaceRole,
WorkspaceSpaceBilling,
} from '@/app/infra/entities/workspace';
/**
@@ -1146,10 +1147,8 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
}
public getSpaceCredits(): Promise<{ credits: number | null }> {
return this.get('/api/v1/user/space-credits', undefined, {
skipWorkspace: true,
});
public getWorkspaceSpaceBilling(): Promise<WorkspaceSpaceBilling> {
return this.get('/api/v1/user/space-credits');
}
public getAccountInfo(): Promise<{
+13 -27
View File
@@ -22,7 +22,6 @@ import type {
} from '@/app/infra/entities/workspace';
import {
backendClient,
beginAuthenticatedSession,
bootstrapWorkspaceSession,
clearPendingInvitationToken,
clearUserInfo,
@@ -168,10 +167,12 @@ export default function AcceptInvitationPage() {
token,
registration,
);
beginAuthenticatedSession(
response.token,
registration?.email ?? view?.invitation.normalized_email,
);
if (registration) {
clearPendingInvitationToken();
toast.success(t('workspace.invitationAccepted'));
navigate('/login?invitation=1', { replace: true });
return;
}
clearPendingInvitationToken();
const workspaceResult = await bootstrapWorkspaceSession({
preferredWorkspaceUuid: response.workspace_uuid,
@@ -218,13 +219,14 @@ export default function AcceptInvitationPage() {
});
}
function switchAccount() {
function logoutAndReturn() {
if (token) setPendingInvitationToken(token);
clearUserInfo();
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
}
navigate('/login?invitation=1&auto=space', { replace: true });
navigate('/login?invitation=1', { replace: true });
}
function returnToLogin() {
@@ -238,11 +240,6 @@ export default function AcceptInvitationPage() {
const hasLoginToken =
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'));
const currentEmail =
typeof window !== 'undefined' ? localStorage.getItem('userEmail') : null;
const currentAccountMatches =
currentEmail?.trim().toLocaleLowerCase() ===
view?.invitation.normalized_email.toLocaleLowerCase();
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
@@ -307,24 +304,13 @@ export default function AcceptInvitationPage() {
</div>
)}
{hasLoginToken && currentAccountMatches ? (
<Button
className="w-full"
disabled={status === 'submitting'}
onClick={() => void finishAcceptance()}
>
{status === 'submitting' && (
<Loader2 className="size-4 animate-spin" />
)}
{t('workspace.acceptAsCurrentAccount')}
</Button>
) : hasLoginToken ? (
{hasLoginToken ? (
<div className="space-y-3">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
{t('workspace.invitationEmailMismatch')}
{t('workspace.authenticatedInvitationNotice')}
</div>
<Button className="w-full" onClick={switchAccount}>
{t('workspace.switchAccount')}
<Button className="w-full" onClick={logoutAndReturn}>
{t('workspace.logoutAndReturn')}
</Button>
</div>
) : passwordRegistrationEnabled ? (
+14 -1
View File
@@ -279,6 +279,10 @@ const enUS = {
credits: 'Credits',
loginWithSpace: 'Login with LangBot Account',
loginToUseModels: 'Login with Space to use cloud models',
ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.',
usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.",
noModels: 'No models configured',
langbotModels: 'LangBot Models',
spaceTrialTooltip:
@@ -1283,7 +1287,13 @@ const enUS = {
'Invalid bind request. Please try again from account settings.',
setPasswordHint: 'Set a password to login with email and password',
spaceEmailMismatch:
'Space login email does not match the local account email',
'The Space login email does not match the local account email.',
space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required',
space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.',
},
workspace: {
title: 'Workspace',
@@ -1339,6 +1349,9 @@ const enUS = {
existingAccountLoginRequired:
'An account already exists for this email. Sign in to continue.',
acceptAsCurrentAccount: 'Accept with current account',
authenticatedInvitationNotice:
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
logoutAndReturn: 'Sign out and return to this invitation',
switchAccount: 'Switch account',
registerAndAccept: 'Create account and accept',
alreadyHaveAccount: 'I already have an account',
+13
View File
@@ -284,6 +284,10 @@ const jaJP = {
credits: 'クレジット',
loginWithSpace: 'LangBot アカウントでログイン',
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
ownerMustBindSpace:
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。',
usesOwnerSpaceBilling:
'ワークスペース所有者の Space 課金とクレジットを使用します。',
noModels: 'モデルがありません',
langbotModels: 'LangBot モデル',
spaceTrialTooltip:
@@ -1289,6 +1293,12 @@ const jaJP = {
'パスワードを設定するとメールとパスワードでログインできます',
spaceEmailMismatch:
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
space_account_not_registeredTitle: 'アカウントが登録されていません',
space_account_not_registered:
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
space_account_binding_requiredTitle: 'Space の連携が必要です',
space_account_binding_required:
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。',
},
workspace: {
title: 'ワークスペース',
@@ -1340,6 +1350,9 @@ const jaJP = {
existingAccountLoginRequired:
'このメールアドレスのアカウントは既に存在します。ログインしてください。',
acceptAsCurrentAccount: '現在のアカウントで承認',
authenticatedInvitationNotice:
'一度ログアウトし、招待されたアカウントでログインしてください。招待は保持されます。',
logoutAndReturn: 'ログアウトしてこの招待に戻る',
switchAccount: 'アカウントを切り替える',
registerAndAccept: 'アカウントを作成して承認',
alreadyHaveAccount: 'アカウントを持っています',
+11
View File
@@ -267,6 +267,8 @@ const zhHans = {
credits: '积分',
loginWithSpace: '使用 LangBot 账号登录',
loginToUseModels: '通过 Space 登录以使用云端模型',
ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。',
usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。',
noModels: '暂无模型',
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
@@ -1219,6 +1221,12 @@ const zhHans = {
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
space_account_not_registeredTitle: '账户尚未注册',
space_account_not_registered:
'此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。',
space_account_binding_requiredTitle: '需要绑定 Space',
space_account_binding_required:
'此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。',
},
workspace: {
title: '工作区',
@@ -1270,6 +1278,9 @@ const zhHans = {
invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
acceptAsCurrentAccount: '使用当前账户接受',
authenticatedInvitationNotice:
'请先退出,再使用受邀账户登录。邀请令牌会被保留。',
logoutAndReturn: '退出并返回此邀请',
switchAccount: '切换账号',
registerAndAccept: '创建账户并接受',
alreadyHaveAccount: '我已有账户',