Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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.",
Expand Down Expand Up @@ -375,17 +380,34 @@
"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",
"github.install_button": "Install on GitHub",
"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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions app/components/molecules/ConfirmDeleteDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ watch(open, (isOpen) => {
autocomplete="off"
/>
</div>

<!-- Optional caller-supplied extras (additional options / toggles) -->
<div v-if="$slots.extra" class="mt-4">
<slot name="extra" />
</div>
</div>

<!-- Footer -->
Expand Down
9 changes: 8 additions & 1 deletion app/components/organisms/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}))
})

Expand Down Expand Up @@ -284,7 +285,13 @@ function onProjectDeleted() {
class="icon-[annon--folder] size-4 shrink-0" :class="link.active ? 'opacity-100' : 'opacity-60'"
aria-hidden="true"
/>
<span class="min-w-0 truncate">{{ link.label }}</span>
<span class="min-w-0 flex-1 truncate">{{ link.label }}</span>
<span
v-if="link.accessStatus !== 'accessible'"
class="size-1.5 shrink-0 rounded-full"
:class="link.accessStatus === 'deleted' ? 'bg-danger-400' : 'bg-warning-400'"
:title="link.accessStatus === 'deleted' ? t('projects.access_deleted_badge') : t('projects.access_inaccessible_badge')"
/>
</NuxtLink>
</li>
<li v-if="activeWorkspace && isOwnerOrAdmin" class="pt-1">
Expand Down
210 changes: 209 additions & 1 deletion app/components/organisms/ConnectRepoDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,25 @@ const toast = useToast()
const open = defineModel<boolean>('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<DialogState>('install')
const availableInstallations = ref<AvailableInstallation[]>([])
const availableLoading = ref(false)
const availableError = ref<'unauthorized' | 'generic' | null>(null)
const connectingInstallation = ref<number | null>(null)
const repos = ref<Array<{
id: number
fullName: string
Expand Down Expand Up @@ -42,6 +58,8 @@ watch(open, async (isOpen) => {
selectedRepo.value = null
scanResult.value = null
searchQuery.value = ''
availableInstallations.value = []
availableError.value = null
return
}

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -240,13 +321,140 @@ onUnmounted(() => window.removeEventListener('focus', onWindowFocus))
<p class="mt-4 text-xs text-muted">
{{ t('github.install_hint') }}
</p>

<!-- Or connect an existing installation -->
<div class="mx-auto mt-6 flex max-w-xs items-center gap-3">
<div class="h-px flex-1 bg-secondary-200 dark:bg-secondary-800" />
<span class="text-xs uppercase tracking-wider text-disabled">{{ t('common.or') }}</span>
<div class="h-px flex-1 bg-secondary-200 dark:bg-secondary-800" />
</div>
<button
type="button"
class="mt-4 inline-flex items-center gap-1.5 rounded text-sm text-primary-600 transition-colors hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 dark:text-primary-400 dark:hover:text-primary-300"
@click="openConnectExisting"
>
<span class="icon-[annon--link-1] size-3.5" aria-hidden="true" />
<span>{{ t('github.connect_existing_button') }}</span>
</button>
<p class="mt-2 text-xs text-muted">
{{ t('github.connect_existing_hint') }}
</p>

<!-- Auto-checking indicator -->
<div v-if="checkingInstall" class="mt-3 flex items-center justify-center gap-2 text-xs text-muted">
<div class="size-3 animate-spin rounded-full border-2 border-secondary-300 border-t-primary-500" />
<span>{{ t('common.loading') }}</span>
</div>
</div>

<!-- STATE: Connect existing installation -->
<div v-else-if="state === 'connect-existing'" class="flex max-h-[60vh] flex-col">
<!-- Header sub-row -->
<div class="flex items-center gap-3 border-b border-secondary-200 px-6 py-3 dark:border-secondary-800">
<button
type="button"
class="rounded p-1 text-muted transition-colors hover:bg-secondary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 dark:hover:bg-secondary-900"
@click="state = 'install'"
>
<span class="icon-[annon--arrow-left] block size-4" aria-hidden="true" />
<span class="sr-only">{{ t('common.back') }}</span>
</button>
<p class="text-sm font-medium text-heading dark:text-secondary-100">
{{ t('github.connect_existing_picker_title') }}
</p>
</div>

<!-- Body -->
<div class="flex-1 overflow-y-auto px-3 py-2">
<!-- Reauth required -->
<div v-if="availableError === 'unauthorized'" class="px-3 py-6">
<AtomsEmptyState
icon="icon-[annon--alert-circle]"
:title="t('github.user_token_missing_title')"
:description="t('github.user_token_missing')"
>
<template #action>
<AtomsBaseButton size="sm" variant="primary" @click="reconnectGitHub">
<template #prepend>
<span class="icon-[annon--arrow-swap] size-3.5" aria-hidden="true" />
</template>
{{ t('github.reconnect_button') }}
</AtomsBaseButton>
</template>
</AtomsEmptyState>
</div>

<!-- Generic error -->
<div v-else-if="availableError === 'generic'" class="px-3 py-6">
<AtomsEmptyState
icon="icon-[annon--alert-triangle]"
:title="t('github.connect_existing_error')"
:description="t('github.connect_existing_error_hint')"
/>
</div>

<!-- Loading -->
<div v-else-if="availableLoading" class="space-y-2 px-3 py-2">
<AtomsSkeleton v-for="i in 3" :key="i" variant="custom" class="h-14 w-full rounded-lg" />
</div>

<!-- Empty -->
<div v-else-if="availableInstallations.length === 0" class="px-3 py-6">
<AtomsEmptyState
icon="icon-[annon--search]"
:title="t('github.no_existing_installations')"
:description="t('github.no_existing_installations_hint')"
>
<template #action>
<AtomsBaseButton size="sm" variant="primary" @click="installGitHubApp">
<template #prepend>
<span class="icon-[annon--external-link] size-3.5" aria-hidden="true" />
</template>
{{ t('github.install_button') }}
</AtomsBaseButton>
</template>
</AtomsEmptyState>
</div>

<!-- List -->
<ul v-else class="space-y-1">
<li v-for="inst in availableInstallations" :key="inst.id">
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-secondary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent dark:hover:bg-secondary-900"
:disabled="!!inst.boundWorkspace || connectingInstallation === inst.id"
@click="connectExistingInstallation(inst)"
>
<AtomsAvatar
v-if="inst.account.avatarUrl"
:src="inst.account.avatarUrl"
:name="inst.account.login"
size="sm"
/>
<div v-else class="flex size-8 items-center justify-center rounded-full bg-secondary-100 dark:bg-secondary-800">
<span class="icon-[annon--user] size-4 text-muted" aria-hidden="true" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium text-heading dark:text-secondary-100">
{{ inst.account.login ?? '—' }}
</span>
<AtomsBadge :variant="inst.targetType === 'Organization' ? 'info' : 'secondary'" size="sm">
{{ inst.targetType === 'Organization' ? t('settings.github_org') : t('settings.github_user') }}
</AtomsBadge>
</div>
<div v-if="inst.boundWorkspace" class="mt-0.5 truncate text-xs text-muted">
{{ t('github.installation_already_bound').replace('{workspace}', inst.boundWorkspace.name) }}
</div>
</div>
<div v-if="connectingInstallation === inst.id" class="size-4 shrink-0 animate-spin rounded-full border-2 border-secondary-300 border-t-primary-500" />
<span v-else-if="!inst.boundWorkspace" class="icon-[annon--chevron-right] size-4 shrink-0 text-muted" aria-hidden="true" />
</button>
</li>
</ul>
</div>
</div>

<!-- STATE 2: Select Repository -->
<div v-else-if="state === 'select'" class="flex max-h-[60vh] flex-col">
<!-- Search -->
Expand Down
Loading
Loading