| <template> |
| <div |
| class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/30" |
| > |
| <div class="flex items-start gap-4"> |
| <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"> |
| <Icon name="link" size="md" class="text-white" /> |
| </div> |
| <div class="flex-1"> |
| <h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4> |
| |
| |
| <div v-if="showMethodSelection" class="mb-4"> |
| <label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300"> |
| {{ methodLabel }} |
| </label> |
| <div class="flex flex-wrap gap-4"> |
| <label class="flex cursor-pointer items-center gap-2"> |
| <input |
| v-model="inputMethod" |
| type="radio" |
| value="manual" |
| class="text-blue-600 focus:ring-blue-500" |
| /> |
| <span class="text-sm text-blue-900 dark:text-blue-200">{{ |
| t('admin.accounts.oauth.manualAuth') |
| }}</span> |
| </label> |
| <label v-if="showCookieOption" class="flex cursor-pointer items-center gap-2"> |
| <input |
| v-model="inputMethod" |
| type="radio" |
| value="cookie" |
| class="text-blue-600 focus:ring-blue-500" |
| /> |
| <span class="text-sm text-blue-900 dark:text-blue-200">{{ |
| t('admin.accounts.oauth.cookieAutoAuth') |
| }}</span> |
| </label> |
| <label v-if="showRefreshTokenOption" class="flex cursor-pointer items-center gap-2"> |
| <input |
| v-model="inputMethod" |
| type="radio" |
| value="refresh_token" |
| class="text-blue-600 focus:ring-blue-500" |
| /> |
| <span class="text-sm text-blue-900 dark:text-blue-200">{{ |
| t(getOAuthKey('refreshTokenAuth')) |
| }}</span> |
| </label> |
| <label v-if="showSessionTokenOption" class="flex cursor-pointer items-center gap-2"> |
| <input |
| v-model="inputMethod" |
| type="radio" |
| value="session_token" |
| class="text-blue-600 focus:ring-blue-500" |
| /> |
| <span class="text-sm text-blue-900 dark:text-blue-200">{{ |
| t(getOAuthKey('sessionTokenAuth')) |
| }}</span> |
| </label> |
| <label v-if="showAccessTokenOption" class="flex cursor-pointer items-center gap-2"> |
| <input |
| v-model="inputMethod" |
| type="radio" |
| value="access_token" |
| class="text-blue-600 focus:ring-blue-500" |
| /> |
| <span class="text-sm text-blue-900 dark:text-blue-200">{{ |
| t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT') |
| }}</span> |
| </label> |
| </div> |
| </div> |
| |
| |
| <div v-if="inputMethod === 'refresh_token'" class="space-y-4"> |
| <div |
| class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
| > |
| <p class="mb-3 text-sm text-blue-700 dark:text-blue-300"> |
| {{ t(getOAuthKey('refreshTokenDesc')) }} |
| </p> |
| |
| |
| <div class="mb-4"> |
| <label |
| class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300" |
| > |
| <Icon name="key" size="sm" class="text-blue-500" /> |
| Refresh Token |
| <span |
| v-if="parsedRefreshTokenCount > 1" |
| class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white" |
| > |
| {{ t('admin.accounts.oauth.keysCount', { count: parsedRefreshTokenCount }) }} |
| </span> |
| </label> |
| <textarea |
| v-model="refreshTokenInput" |
| rows="3" |
| class="input w-full resize-y font-mono text-sm" |
| :placeholder="t(getOAuthKey('refreshTokenPlaceholder'))" |
| ></textarea> |
| <p |
| v-if="parsedRefreshTokenCount > 1" |
| class="mt-1 text-xs text-blue-600 dark:text-blue-400" |
| > |
| {{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedRefreshTokenCount }) }} |
| </p> |
| </div> |
| |
| |
| <div |
| v-if="error" |
| class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30" |
| > |
| <p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400"> |
| {{ error }} |
| </p> |
| </div> |
| |
| |
| <button |
| type="button" |
| class="btn btn-primary w-full" |
| :disabled="loading || !refreshTokenInput.trim()" |
| @click="handleValidateRefreshToken" |
| > |
| <svg |
| v-if="loading" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| <Icon v-else name="sparkles" size="sm" class="mr-2" /> |
| {{ |
| loading |
| ? t(getOAuthKey('validating')) |
| : t(getOAuthKey('validateAndCreate')) |
| }} |
| </button> |
| </div> |
| </div> |
| |
| |
| <div v-if="inputMethod === 'session_token'" class="space-y-4"> |
| <div |
| class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
| > |
| <p class="mb-3 text-sm text-blue-700 dark:text-blue-300"> |
| {{ t(getOAuthKey('sessionTokenDesc')) }} |
| </p> |
| |
| <div class="mb-4"> |
| <label |
| class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300" |
| > |
| <Icon name="key" size="sm" class="text-blue-500" /> |
| {{ t(getOAuthKey('sessionTokenRawLabel')) }} |
| <span |
| v-if="parsedSessionTokenCount > 1" |
| class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white" |
| > |
| {{ t('admin.accounts.oauth.keysCount', { count: parsedSessionTokenCount }) }} |
| </span> |
| </label> |
| <textarea |
| v-model="sessionTokenInput" |
| rows="3" |
| class="input w-full resize-y font-mono text-sm" |
| :placeholder="t(getOAuthKey('sessionTokenRawPlaceholder'))" |
| ></textarea> |
| <p class="mt-1 text-xs text-blue-600 dark:text-blue-400"> |
| {{ t(getOAuthKey('sessionTokenRawHint')) }} |
| </p> |
| <div class="mt-2 flex flex-wrap items-center gap-2"> |
| <button |
| type="button" |
| class="btn btn-secondary px-2 py-1 text-xs" |
| @click="handleOpenSoraSessionUrl" |
| > |
| {{ t(getOAuthKey('openSessionUrl')) }} |
| </button> |
| <button |
| type="button" |
| class="btn btn-secondary px-2 py-1 text-xs" |
| @click="handleCopySoraSessionUrl" |
| > |
| {{ t(getOAuthKey('copySessionUrl')) }} |
| </button> |
| </div> |
| <p class="mt-1 break-all text-xs text-blue-600 dark:text-blue-400"> |
| {{ soraSessionUrl }} |
| </p> |
| <p class="mt-1 text-xs text-amber-600 dark:text-amber-400"> |
| {{ t(getOAuthKey('sessionUrlHint')) }} |
| </p> |
| <p |
| v-if="parsedSessionTokenCount > 1" |
| class="mt-1 text-xs text-blue-600 dark:text-blue-400" |
| > |
| {{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedSessionTokenCount }) }} |
| </p> |
| </div> |
| |
| <div v-if="sessionTokenInput.trim()" class="mb-4 space-y-3"> |
| <div> |
| <label |
| class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300" |
| > |
| {{ t(getOAuthKey('parsedSessionTokensLabel')) }} |
| <span |
| v-if="parsedSessionTokenCount > 0" |
| class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white" |
| > |
| {{ parsedSessionTokenCount }} |
| </span> |
| </label> |
| <textarea |
| :value="parsedSessionTokensText" |
| rows="2" |
| readonly |
| class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700" |
| ></textarea> |
| <p |
| v-if="parsedSessionTokenCount === 0" |
| class="mt-1 text-xs text-amber-600 dark:text-amber-400" |
| > |
| {{ t(getOAuthKey('parsedSessionTokensEmpty')) }} |
| </p> |
| </div> |
| |
| <div> |
| <label |
| class="mb-2 flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300" |
| > |
| {{ t(getOAuthKey('parsedAccessTokensLabel')) }} |
| <span |
| v-if="parsedAccessTokenFromSessionInputCount > 0" |
| class="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] text-white" |
| > |
| {{ parsedAccessTokenFromSessionInputCount }} |
| </span> |
| </label> |
| <textarea |
| :value="parsedAccessTokensText" |
| rows="2" |
| readonly |
| class="input w-full resize-y bg-gray-50 font-mono text-xs dark:bg-gray-700" |
| ></textarea> |
| </div> |
| </div> |
| |
| <div |
| v-if="error" |
| class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30" |
| > |
| <p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400"> |
| {{ error }} |
| </p> |
| </div> |
| |
| <button |
| type="button" |
| class="btn btn-primary w-full" |
| :disabled="loading || parsedSessionTokenCount === 0" |
| @click="handleValidateSessionToken" |
| > |
| <svg |
| v-if="loading" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| <Icon v-else name="sparkles" size="sm" class="mr-2" /> |
| {{ |
| loading |
| ? t(getOAuthKey('validating')) |
| : t(getOAuthKey('validateAndCreate')) |
| }} |
| </button> |
| </div> |
| </div> |
| |
| |
| <div v-if="inputMethod === 'access_token'" class="space-y-4"> |
| <div |
| class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
| > |
| <p class="mb-3 text-sm text-blue-700 dark:text-blue-300"> |
| {{ t('admin.accounts.oauth.openai.accessTokenDesc', '直接粘贴 Access Token 创建账号,无需 OAuth 授权流程。支持批量导入(每行一个)。') }} |
| </p> |
| |
| <div class="mb-4"> |
| <label |
| class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300" |
| > |
| <Icon name="key" size="sm" class="text-blue-500" /> |
| Access Token |
| <span |
| v-if="parsedAccessTokenCount > 1" |
| class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white" |
| > |
| {{ t('admin.accounts.oauth.keysCount', { count: parsedAccessTokenCount }) }} |
| </span> |
| </label> |
| <textarea |
| v-model="accessTokenInput" |
| rows="3" |
| class="input w-full resize-y font-mono text-sm" |
| :placeholder="t('admin.accounts.oauth.openai.accessTokenPlaceholder', '粘贴 Access Token,每行一个')" |
| ></textarea> |
| <p |
| v-if="parsedAccessTokenCount > 1" |
| class="mt-1 text-xs text-blue-600 dark:text-blue-400" |
| > |
| {{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedAccessTokenCount }) }} |
| </p> |
| </div> |
| |
| <div |
| v-if="error" |
| class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30" |
| > |
| <p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400"> |
| {{ error }} |
| </p> |
| </div> |
| |
| <button |
| type="button" |
| class="btn btn-primary w-full" |
| :disabled="loading || !accessTokenInput.trim()" |
| @click="handleImportAccessToken" |
| > |
| <Icon name="sparkles" size="sm" class="mr-2" /> |
| {{ t('admin.accounts.oauth.openai.importAccessToken', '导入 Access Token') }} |
| </button> |
| </div> |
| </div> |
| |
| |
| <div v-if="inputMethod === 'cookie'" class="space-y-4"> |
| <div |
| class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
| > |
| <p class="mb-3 text-sm text-blue-700 dark:text-blue-300"> |
| {{ t('admin.accounts.oauth.cookieAutoAuthDesc') }} |
| </p> |
| |
| |
| <div class="mb-4"> |
| <label |
| class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300" |
| > |
| <Icon name="key" size="sm" class="text-blue-500" /> |
| {{ t('admin.accounts.oauth.sessionKey') }} |
| <span |
| v-if="parsedKeyCount > 1 && allowMultiple" |
| class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white" |
| > |
| {{ t('admin.accounts.oauth.keysCount', { count: parsedKeyCount }) }} |
| </span> |
| <button |
| v-if="showHelp" |
| type="button" |
| class="text-blue-500 hover:text-blue-600" |
| @click="showHelpDialog = !showHelpDialog" |
| > |
| <svg |
| class="h-4 w-4" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| stroke-width="1.5" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" |
| /> |
| </svg> |
| </button> |
| </label> |
| <textarea |
| v-model="sessionKeyInput" |
| rows="3" |
| class="input w-full resize-y font-mono text-sm" |
| :placeholder=" |
| allowMultiple |
| ? t('admin.accounts.oauth.sessionKeyPlaceholder') |
| : t('admin.accounts.oauth.sessionKeyPlaceholderSingle') |
| " |
| ></textarea> |
| <p |
| v-if="parsedKeyCount > 1 && allowMultiple" |
| class="mt-1 text-xs text-blue-600 dark:text-blue-400" |
| > |
| {{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedKeyCount }) }} |
| </p> |
| </div> |
| |
| |
| <div |
| v-if="showHelpDialog && showHelp" |
| class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30" |
| > |
| <h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200"> |
| {{ t('admin.accounts.oauth.howToGetSessionKey') }} |
| </h5> |
| <ol |
| class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300" |
| > |
| <li>{{ t('admin.accounts.oauth.step1') }}</li> |
| <li>{{ t('admin.accounts.oauth.step2') }}</li> |
| <li>{{ t('admin.accounts.oauth.step3') }}</li> |
| <li>{{ t('admin.accounts.oauth.step4') }}</li> |
| <li>{{ t('admin.accounts.oauth.step5') }}</li> |
| <li>{{ t('admin.accounts.oauth.step6') }}</li> |
| </ol> |
| <p |
| class="mt-2 text-xs text-amber-600 dark:text-amber-400" |
| v-text="t('admin.accounts.oauth.sessionKeyFormat')" |
| ></p> |
| </div> |
| |
| |
| <div |
| v-if="error" |
| class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30" |
| > |
| <p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400"> |
| {{ error }} |
| </p> |
| </div> |
| |
| |
| <button |
| type="button" |
| class="btn btn-primary w-full" |
| :disabled="loading || !sessionKeyInput.trim()" |
| @click="handleCookieAuth" |
| > |
| <svg |
| v-if="loading" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| <Icon v-else name="sparkles" size="sm" class="mr-2" /> |
| {{ |
| loading |
| ? t('admin.accounts.oauth.authorizing') |
| : t('admin.accounts.oauth.startAutoAuth') |
| }} |
| </button> |
| </div> |
| </div> |
| |
| |
| <div v-if="inputMethod === 'manual'" class="space-y-4"> |
| <p class="mb-4 text-sm text-blue-800 dark:text-blue-300"> |
| {{ oauthFollowSteps }} |
| </p> |
| |
| |
| <div |
| class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
| > |
| <div class="flex items-start gap-3"> |
| <div |
| class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" |
| > |
| 1 |
| </div> |
| <div class="flex-1"> |
| <p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> |
| {{ oauthStep1GenerateUrl }} |
| </p> |
| <div v-if="showProjectId && platform === 'gemini'" class="mb-3"> |
| <label class="input-label flex items-center gap-2"> |
| {{ t('admin.accounts.oauth.gemini.projectIdLabel') }} |
| <a |
| href="https://console.cloud.google.com/" |
| target="_blank" |
| rel="noopener noreferrer" |
| class="inline-flex items-center gap-1 text-xs font-normal text-blue-500 hover:text-blue-600 dark:text-blue-400" |
| > |
| <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" /> |
| </svg> |
| {{ t('admin.accounts.oauth.gemini.howToGetProjectId') }} |
| </a> |
| </label> |
| <input |
| v-model="projectId" |
| type="text" |
| class="input w-full font-mono text-sm" |
| :placeholder="t('admin.accounts.oauth.gemini.projectIdPlaceholder')" |
| /> |
| <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> |
| {{ t('admin.accounts.oauth.gemini.projectIdHint') }} |
| </p> |
| </div> |
| <button |
| v-if="!authUrl" |
| type="button" |
| :disabled="loading" |
| class="btn btn-primary text-sm" |
| @click="handleGenerateUrl" |
| > |
| <svg |
| v-if="loading" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| <Icon v-else name="link" size="sm" class="mr-2" /> |
| {{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }} |
| </button> |
| <div v-else class="space-y-3"> |
| <div class="flex items-center gap-2"> |
| <input |
| :value="authUrl" |
| readonly |
| type="text" |
| class="input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700" |
| /> |
| <button |
| type="button" |
| class="btn btn-secondary p-2" |
| title="Copy URL" |
| @click="handleCopyUrl" |
| > |
| <svg |
| v-if="!copied" |
| class="h-4 w-4" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| stroke-width="1.5" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" |
| /> |
| </svg> |
| <Icon |
| v-else |
| name="check" |
| size="sm" |
| class="text-green-500" |
| :stroke-width="2" |
| /> |
| </button> |
| </div> |
| <button |
| type="button" |
| class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400" |
| @click="handleRegenerate" |
| > |
| <Icon name="refresh" size="xs" class="mr-1 inline" /> |
| {{ t('admin.accounts.oauth.regenerate') }} |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div |
| class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
| > |
| <div class="flex items-start gap-3"> |
| <div |
| class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" |
| > |
| 2 |
| </div> |
| <div class="flex-1"> |
| <p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> |
| {{ oauthStep2OpenUrl }} |
| </p> |
| <p class="text-sm text-blue-700 dark:text-blue-300"> |
| {{ oauthOpenUrlDesc }} |
| </p> |
| |
| <div |
| v-if="isOpenAI" |
| class="mt-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30" |
| > |
| <p |
| class="text-xs text-amber-800 dark:text-amber-300" |
| v-text="oauthImportantNotice" |
| ></p> |
| </div> |
| |
| <div |
| v-else-if="showProxyWarning" |
| class="mt-2 rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30" |
| > |
| <p |
| class="text-xs text-yellow-800 dark:text-yellow-300" |
| v-text="t('admin.accounts.oauth.proxyWarning')" |
| ></p> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div |
| class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" |
| > |
| <div class="flex items-start gap-3"> |
| <div |
| class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white" |
| > |
| 3 |
| </div> |
| <div class="flex-1"> |
| <p class="mb-2 font-medium text-blue-900 dark:text-blue-200"> |
| {{ oauthStep3EnterCode }} |
| </p> |
| <p |
| class="mb-3 text-sm text-blue-700 dark:text-blue-300" |
| v-text="oauthAuthCodeDesc" |
| ></p> |
| <div> |
| <label class="input-label"> |
| <Icon name="key" size="sm" class="mr-1 inline text-blue-500" /> |
| {{ oauthAuthCode }} |
| </label> |
| <textarea |
| v-model="authCodeInput" |
| rows="3" |
| class="input w-full resize-none font-mono text-sm" |
| :placeholder="oauthAuthCodePlaceholder" |
| ></textarea> |
| <p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
| <Icon name="infoCircle" size="xs" class="mr-1 inline" /> |
| {{ oauthAuthCodeHint }} |
| </p> |
| |
| |
| <div |
| v-if="platform === 'gemini'" |
| class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 p-3 dark:border-amber-600 dark:bg-amber-900/30" |
| > |
| <div class="flex items-start gap-2"> |
| <Icon |
| name="exclamationTriangle" |
| size="md" |
| class="flex-shrink-0 text-amber-600 dark:text-amber-400" |
| :stroke-width="2" |
| /> |
| <div class="text-sm text-amber-800 dark:text-amber-300"> |
| <p class="font-semibold">{{ $t('admin.accounts.oauth.gemini.stateWarningTitle') }}</p> |
| <p class="mt-1">{{ $t('admin.accounts.oauth.gemini.stateWarningDesc') }}</p> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div |
| v-if="error" |
| class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30" |
| > |
| <p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400"> |
| {{ error }} |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, computed, watch } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { useClipboard } from '@/composables/useClipboard' |
| import { parseSoraRawTokens } from '@/utils/soraTokenParser' |
| import Icon from '@/components/icons/Icon.vue' |
| import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth' |
| import type { AccountPlatform } from '@/types' |
| |
| interface Props { |
| addMethod: AddMethod |
| authUrl?: string |
| sessionId?: string |
| loading?: boolean |
| error?: string |
| showHelp?: boolean |
| showProxyWarning?: boolean |
| allowMultiple?: boolean |
| methodLabel?: string |
| showCookieOption?: boolean |
| showRefreshTokenOption?: boolean |
| showSessionTokenOption?: boolean |
| showAccessTokenOption?: boolean |
| platform?: AccountPlatform |
| showProjectId?: boolean |
| } |
| |
| const props = withDefaults(defineProps<Props>(), { |
| authUrl: '', |
| sessionId: '', |
| loading: false, |
| error: '', |
| showHelp: true, |
| showProxyWarning: true, |
| allowMultiple: false, |
| methodLabel: 'Authorization Method', |
| showCookieOption: true, |
| showRefreshTokenOption: false, |
| showSessionTokenOption: false, |
| showAccessTokenOption: false, |
| platform: 'anthropic', |
| showProjectId: true |
| }) |
| |
| const emit = defineEmits<{ |
| 'generate-url': [] |
| 'exchange-code': [code: string] |
| 'cookie-auth': [sessionKey: string] |
| 'validate-refresh-token': [refreshToken: string] |
| 'validate-session-token': [sessionToken: string] |
| 'import-access-token': [accessToken: string] |
| 'update:inputMethod': [method: AuthInputMethod] |
| }>() |
| |
| const { t } = useI18n() |
| |
| const isOpenAI = computed(() => props.platform === 'openai' || props.platform === 'sora') |
| |
| |
| const getOAuthKey = (key: string) => { |
| if (props.platform === 'openai' || props.platform === 'sora') return `admin.accounts.oauth.openai.${key}` |
| if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}` |
| if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}` |
| return `admin.accounts.oauth.${key}` |
| } |
| |
| |
| const oauthTitle = computed(() => t(getOAuthKey('title'))) |
| const oauthFollowSteps = computed(() => t(getOAuthKey('followSteps'))) |
| const oauthStep1GenerateUrl = computed(() => t(getOAuthKey('step1GenerateUrl'))) |
| const oauthGenerateAuthUrl = computed(() => t(getOAuthKey('generateAuthUrl'))) |
| const oauthStep2OpenUrl = computed(() => t(getOAuthKey('step2OpenUrl'))) |
| const oauthOpenUrlDesc = computed(() => t(getOAuthKey('openUrlDesc'))) |
| const oauthStep3EnterCode = computed(() => t(getOAuthKey('step3EnterCode'))) |
| const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc'))) |
| const oauthAuthCode = computed(() => t(getOAuthKey('authCode'))) |
| const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder'))) |
| const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint'))) |
| const oauthImportantNotice = computed(() => { |
| if (props.platform === 'openai' || props.platform === 'sora') return t('admin.accounts.oauth.openai.importantNotice') |
| if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice') |
| return '' |
| }) |
| |
| |
| const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual') |
| const authCodeInput = ref('') |
| const sessionKeyInput = ref('') |
| const refreshTokenInput = ref('') |
| const sessionTokenInput = ref('') |
| const accessTokenInput = ref('') |
| const showHelpDialog = ref(false) |
| const oauthState = ref('') |
| const projectId = ref('') |
| |
| |
| const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption) |
| |
| |
| const { copied, copyToClipboard } = useClipboard() |
| |
| |
| const parsedKeyCount = computed(() => { |
| return sessionKeyInput.value |
| .split('\n') |
| .map((k) => k.trim()) |
| .filter((k) => k).length |
| }) |
| |
| |
| const parsedRefreshTokenCount = computed(() => { |
| return refreshTokenInput.value |
| .split('\n') |
| .map((rt) => rt.trim()) |
| .filter((rt) => rt).length |
| }) |
| |
| const parsedSoraRawTokens = computed(() => parseSoraRawTokens(sessionTokenInput.value)) |
| |
| const parsedSessionTokenCount = computed(() => { |
| return parsedSoraRawTokens.value.sessionTokens.length |
| }) |
| |
| const parsedSessionTokensText = computed(() => { |
| return parsedSoraRawTokens.value.sessionTokens.join('\n') |
| }) |
| |
| const parsedAccessTokenFromSessionInputCount = computed(() => { |
| return parsedSoraRawTokens.value.accessTokens.length |
| }) |
| |
| const parsedAccessTokensText = computed(() => { |
| return parsedSoraRawTokens.value.accessTokens.join('\n') |
| }) |
| |
| const soraSessionUrl = 'https://sora.chatgpt.com/api/auth/session' |
| |
| const parsedAccessTokenCount = computed(() => { |
| return accessTokenInput.value |
| .split('\n') |
| .map((at) => at.trim()) |
| .filter((at) => at).length |
| }) |
| |
| |
| watch(inputMethod, (newVal) => { |
| emit('update:inputMethod', newVal) |
| }) |
| |
| |
| |
| watch(authCodeInput, (newVal) => { |
| if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'sora') return |
| |
| const trimmed = newVal.trim() |
| |
| if (trimmed.includes('?') && trimmed.includes('code=')) { |
| try { |
| |
| const url = new URL(trimmed) |
| const code = url.searchParams.get('code') |
| const stateParam = url.searchParams.get('state') |
| if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) { |
| oauthState.value = stateParam |
| } |
| if (code && code !== trimmed) { |
| |
| authCodeInput.value = code |
| } |
| } catch { |
| |
| const match = trimmed.match(/[?&]code=([^&]+)/) |
| const stateMatch = trimmed.match(/[?&]state=([^&]+)/) |
| if ((props.platform === 'openai' || props.platform === 'sora' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) { |
| oauthState.value = stateMatch[1] |
| } |
| if (match && match[1] && match[1] !== trimmed) { |
| authCodeInput.value = match[1] |
| } |
| } |
| } |
| }) |
| |
| |
| const handleGenerateUrl = () => { |
| emit('generate-url') |
| } |
| |
| const handleCopyUrl = () => { |
| if (props.authUrl) { |
| copyToClipboard(props.authUrl, 'URL copied to clipboard') |
| } |
| } |
| |
| const handleRegenerate = () => { |
| authCodeInput.value = '' |
| emit('generate-url') |
| } |
| |
| const handleCookieAuth = () => { |
| if (sessionKeyInput.value.trim()) { |
| emit('cookie-auth', sessionKeyInput.value) |
| } |
| } |
| |
| const handleValidateRefreshToken = () => { |
| if (refreshTokenInput.value.trim()) { |
| emit('validate-refresh-token', refreshTokenInput.value.trim()) |
| } |
| } |
| |
| const handleValidateSessionToken = () => { |
| if (parsedSessionTokenCount.value > 0) { |
| emit('validate-session-token', parsedSessionTokensText.value) |
| } |
| } |
| |
| const handleOpenSoraSessionUrl = () => { |
| window.open(soraSessionUrl, '_blank', 'noopener,noreferrer') |
| } |
| |
| const handleCopySoraSessionUrl = () => { |
| copyToClipboard(soraSessionUrl, 'URL copied to clipboard') |
| } |
| |
| const handleImportAccessToken = () => { |
| if (accessTokenInput.value.trim()) { |
| emit('import-access-token', accessTokenInput.value.trim()) |
| } |
| } |
| |
| |
| defineExpose({ |
| authCode: authCodeInput, |
| oauthState, |
| projectId, |
| sessionKey: sessionKeyInput, |
| refreshToken: refreshTokenInput, |
| sessionToken: sessionTokenInput, |
| inputMethod, |
| reset: () => { |
| authCodeInput.value = '' |
| oauthState.value = '' |
| projectId.value = '' |
| sessionKeyInput.value = '' |
| refreshTokenInput.value = '' |
| sessionTokenInput.value = '' |
| inputMethod.value = 'manual' |
| showHelpDialog.value = false |
| } |
| }) |
| </script> |
| |