diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 8678012d..0815d0a9 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -220,6 +220,7 @@ "common.no": "No", "common.no_results": "No results found", "common.not_connected": "Not connected", + "common.or": "or", "common.remove": "Remove", "common.save": "Save", "common.save_changes": "Save changes", @@ -296,6 +297,8 @@ "conversation_keys.revoked": "Key revoked", "conversation_keys.role": "Role", "conversation_keys.title": "Conversation API", + "danger_zone.cancel_subscription_hint": "End your paid subscription on the billing provider side. Without this, the subscription keeps running and you keep being charged.", + "danger_zone.cancel_subscription_label": "Also cancel active subscription", "danger_zone.deleting": "Deleting...", "danger_zone.project_confirm_label": "Type the repository name to confirm", "danger_zone.project_delete_button": "Delete Project", @@ -304,6 +307,8 @@ "danger_zone.project_delete_title": "Delete this project", "danger_zone.project_deleted": "Project deleted successfully", "danger_zone.title": "Danger Zone", + "danger_zone.uninstall_app_hint": "Remove the GitHub App from your account or organization so the next install starts fresh.", + "danger_zone.uninstall_app_label": "Also uninstall GitHub App", "danger_zone.workspace_confirm_label": "Type the workspace name to confirm", "danger_zone.workspace_delete_button": "Delete Workspace", "danger_zone.workspace_delete_description": "This will permanently delete this workspace and all its projects, conversations, CDN builds, media assets, and member access. This action cannot be undone.", @@ -375,6 +380,11 @@ "forms.tab_submissions": "Submissions", "forms.user_agent": "User Agent", "github.branch_label": "Branch: {branch}", + "github.connect_existing_button": "Connect an existing installation", + "github.connect_existing_error": "Failed to connect installation", + "github.connect_existing_error_hint": "We couldn't link this installation. Please refresh and try again.", + "github.connect_existing_hint": "Already installed the app on your account or organization? Pick from existing installations.", + "github.connect_existing_picker_title": "Connect an existing installation", "github.contentrain_found": ".contentrain/ found — ready to browse", "github.contentrain_missing": ".contentrain/ not found — you can initialize via chat after connecting", "github.framework_detected": "{stack} detected", @@ -382,10 +392,22 @@ "github.install_description": "Connect your GitHub account so Studio can read your repositories and manage content through Git. This is required before creating or connecting projects.", "github.install_hint": "After installing, close this dialog and reopen it.", "github.install_title": "Install GitHub App", + "github.installation_access_denied": "You don't have access to this installation on GitHub.", + "github.installation_already_bound": "Already connected to {workspace}", + "github.manage_app_settings_button": "Manage in GitHub", + "github.no_existing_installations": "No installations found", + "github.no_existing_installations_hint": "You don't have any GitHub App installations yet. Install the app to continue.", "github.no_repos": "No repositories found.", "github.no_repos_hint": "Try adjusting your search or check your GitHub App installation permissions.", + "github.reconnect_button": "Reconnect GitHub", + "github.repo_access_revoked_hint": "GitHub revoked the App's access to this repository. Update the App's repository access in GitHub settings to restore it.", + "github.repo_access_revoked_title": "Repository access revoked", + "github.repo_deleted_hint": "This repository was deleted on GitHub. The project is read-only — you can disconnect it from workspace settings.", + "github.repo_deleted_title": "Repository deleted on GitHub", "github.scan_failed": "Failed to scan repository. Please try again.", "github.unknown_framework": "Unknown framework", + "github.user_token_missing": "Your GitHub authorization has expired or was never granted. Reconnect to list your installations.", + "github.user_token_missing_title": "Reconnect GitHub", "health.ask_agent": "Ask Agent to Fix", "health.critical": "Critical", "health.dismiss": "Dismiss", @@ -556,6 +578,8 @@ "project_settings.workflow_pro_hint": "Upgrade to Pro to enable review workflow with branch-based content approval.", "project_settings.workflow_review": "Review required", "project_settings.workflow_review_desc": "Editors create branches. Reviewers approve before merge.", + "projects.access_deleted_badge": "Repository deleted on GitHub", + "projects.access_inaccessible_badge": "GitHub access revoked", "projects.connect_repo": "Connect repository", "projects.connected_error": "Failed to connect repository", "projects.connected_success": "Repository connected successfully", diff --git a/app/components/molecules/ConfirmDeleteDialog.vue b/app/components/molecules/ConfirmDeleteDialog.vue index 77086a17..391595f4 100644 --- a/app/components/molecules/ConfirmDeleteDialog.vue +++ b/app/components/molecules/ConfirmDeleteDialog.vue @@ -88,6 +88,11 @@ watch(open, (isOpen) => { autocomplete="off" /> + + +
+ +
diff --git a/app/components/organisms/AppSidebar.vue b/app/components/organisms/AppSidebar.vue index 10ad0406..95014645 100644 --- a/app/components/organisms/AppSidebar.vue +++ b/app/components/organisms/AppSidebar.vue @@ -74,6 +74,7 @@ const sidebarLinks = computed(() => { label: p.repo_full_name.split('/').pop() ?? p.repo_full_name, to: `/w/${slug}/projects/${p.id}`, active: p.id === currentProjectId.value, + accessStatus: (p as { access_status?: 'accessible' | 'inaccessible' | 'deleted' }).access_status ?? 'accessible', })) }) @@ -284,7 +285,13 @@ function onProjectDeleted() { class="icon-[annon--folder] size-4 shrink-0" :class="link.active ? 'opacity-100' : 'opacity-60'" aria-hidden="true" /> - {{ link.label }} + {{ link.label }} +
  • diff --git a/app/components/organisms/ConnectRepoDialog.vue b/app/components/organisms/ConnectRepoDialog.vue index fa36e5ba..7a951b3d 100644 --- a/app/components/organisms/ConnectRepoDialog.vue +++ b/app/components/organisms/ConnectRepoDialog.vue @@ -8,9 +8,25 @@ const toast = useToast() const open = defineModel('open', { default: false }) // State machine -type DialogState = 'install' | 'select' | 'confirm' +type DialogState = 'install' | 'connect-existing' | 'select' | 'confirm' + +interface AvailableInstallation { + id: number + account: { + login: string | null + avatarUrl: string | null + type: string | null + } + repositorySelection: 'all' | 'selected' | null + targetType: 'User' | 'Organization' | string | null + boundWorkspace: { id: string, name: string, slug: string } | null +} const state = ref('install') +const availableInstallations = ref([]) +const availableLoading = ref(false) +const availableError = ref<'unauthorized' | 'generic' | null>(null) +const connectingInstallation = ref(null) const repos = ref { selectedRepo.value = null scanResult.value = null searchQuery.value = '' + availableInstallations.value = [] + availableError.value = null return } @@ -157,6 +175,69 @@ function installGitHubApp() { ) } +async function openConnectExisting() { + if (!activeWorkspace.value) return + state.value = 'connect-existing' + availableLoading.value = true + availableError.value = null + availableInstallations.value = [] + + try { + const result = await $fetch<{ installations: AvailableInstallation[] }>( + '/api/github/installations/available', + { params: { workspaceId: activeWorkspace.value.id } }, + ) + availableInstallations.value = result.installations + } + catch (e: unknown) { + const status = (e as { statusCode?: number, data?: { data?: { code?: string } } }).statusCode + const code = (e as { data?: { data?: { code?: string } } }).data?.data?.code + if (status === 401 || code === 'GITHUB_REAUTH_REQUIRED') { + availableError.value = 'unauthorized' + } + else { + availableError.value = 'generic' + } + } + finally { + availableLoading.value = false + } +} + +async function connectExistingInstallation(installation: AvailableInstallation) { + if (!activeWorkspace.value || installation.boundWorkspace) return + connectingInstallation.value = installation.id + + try { + await $fetch('/api/github/installations/connect', { + method: 'POST', + body: { + workspaceId: activeWorkspace.value.id, + installationId: installation.id, + }, + }) + + // Refresh workspace so github_installation_id is current, then load repos. + const { fetchWorkspaces } = useWorkspaces() + await fetchWorkspaces() + state.value = 'select' + await loadRepos() + } + catch (e: unknown) { + toast.error(resolveApiError(e, t('github.connect_existing_error'))) + } + finally { + connectingInstallation.value = null + } +} + +function reconnectGitHub() { + // Trigger Supabase GitHub OAuth flow; on return the OAuth callback + // captures and persists provider_token. After success, the user + // re-opens this dialog and the available list resolves. + window.location.href = `/api/auth/login?provider=github&redirect_to=${encodeURIComponent(window.location.pathname + window.location.search)}` +} + // Auto-detect GitHub App installation when user returns to tab const checkingInstall = ref(false) @@ -240,6 +321,25 @@ onUnmounted(() => window.removeEventListener('focus', onWindowFocus))

    {{ t('github.install_hint') }}

    + + +
    +
    + {{ t('common.or') }} +
    +
    + +

    + {{ t('github.connect_existing_hint') }} +

    +
    @@ -247,6 +347,114 @@ onUnmounted(() => window.removeEventListener('focus', onWindowFocus))
    + +
    + +
    + +

    + {{ t('github.connect_existing_picker_title') }} +

    +
    + + +
    + +
    + + + +
    + + +
    + +
    + + +
    + +
    + + +
    + + + +
    + + +
      +
    • + +
    • +
    +
    +
    +
    diff --git a/app/components/organisms/WorkspaceOverviewPanel.vue b/app/components/organisms/WorkspaceOverviewPanel.vue index d920bad5..13683b09 100644 --- a/app/components/organisms/WorkspaceOverviewPanel.vue +++ b/app/components/organisms/WorkspaceOverviewPanel.vue @@ -114,15 +114,23 @@ async function saveOverview() { } } +const wsDeleteUninstallApp = ref(false) +const wsDeleteCancelSubscription = ref(false) + async function handleDeleteWorkspace() { if (!activeWorkspace.value) return wsDeleting.value = true - const ok = await deleteWorkspace(activeWorkspace.value.id) + const ok = await deleteWorkspace(activeWorkspace.value.id, { + uninstallGithubApp: wsDeleteUninstallApp.value, + cancelSubscription: wsDeleteCancelSubscription.value, + }) wsDeleting.value = false if (ok) { toast.success(t('danger_zone.workspace_deleted')) wsDeleteConfirmOpen.value = false + wsDeleteUninstallApp.value = false + wsDeleteCancelSubscription.value = false const primary = workspaces.value.find(w => w.type === 'primary') if (primary) router.push(`/w/${primary.slug}`) else router.push('/') @@ -239,7 +247,41 @@ async function handleDeleteWorkspace() { :delete-label="wsDeleting ? t('danger_zone.deleting') : t('danger_zone.workspace_delete_button')" :deleting="wsDeleting" @confirm="handleDeleteWorkspace" - /> + > + + { + async function deleteWorkspace( + workspaceId: string, + options?: { uninstallGithubApp?: boolean, cancelSubscription?: boolean }, + ): Promise { try { - await $fetch(`/api/workspaces/${workspaceId}`, { method: 'DELETE' }) + await $fetch(`/api/workspaces/${workspaceId}`, { + method: 'DELETE', + body: options ?? undefined, + }) workspaces.value = workspaces.value.filter(w => w.id !== workspaceId) // Reset active workspace to primary or first available if (activeWorkspaceId.value === workspaceId) { diff --git a/app/pages/w/[slug]/projects/[projectId]/index.vue b/app/pages/w/[slug]/projects/[projectId]/index.vue index 8479ad09..14892e27 100644 --- a/app/pages/w/[slug]/projects/[projectId]/index.vue +++ b/app/pages/w/[slug]/projects/[projectId]/index.vue @@ -242,6 +242,53 @@ async function handleVocabularySave(terms: Record