diff --git a/.github/workflows/ghcr_web_docker.yml b/.github/workflows/ghcr_web_docker.yml new file mode 100644 index 000000000..389fee404 --- /dev/null +++ b/.github/workflows/ghcr_web_docker.yml @@ -0,0 +1,103 @@ +name: Publish AppFlowy Web to GHCR +run-name: Publish AppFlowy Web (${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag_name || github.ref_name }}) + +on: + push: + tags: + - 'v*.*.*' + - '*.*.*' + workflow_dispatch: + inputs: + tag_name: + description: 'Tag for Docker image (example: v0.9.1-patch1)' + required: true + type: string + build_arm64: + description: 'Build ARM64 in addition to AMD64' + required: false + type: boolean + default: true + tag_latest: + description: 'Also push the latest tag' + required: false + type: boolean + default: false + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-22.04 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set variables + id: vars + run: | + OWNER_LC="${GITHUB_REPOSITORY_OWNER,,}" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + IMAGE_TAG="${{ github.event.inputs.tag_name }}" + else + IMAGE_TAG="${GITHUB_REF#refs/tags/}" + fi + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.build_arm64 }}" != "true" ]; then + PLATFORMS="linux/amd64" + else + PLATFORMS="linux/amd64,linux/arm64" + fi + + if [ "${{ github.event_name }}" = "push" ] || [ "${{ github.event.inputs.tag_latest }}" = "true" ]; then + PUSH_LATEST="true" + else + PUSH_LATEST="false" + fi + + IMAGE_BASE="ghcr.io/$OWNER_LC/appflowy_web" + TAGS="$IMAGE_BASE:$IMAGE_TAG" + if [ "$PUSH_LATEST" = "true" ]; then + TAGS="$TAGS"$'\n'"$IMAGE_BASE:latest" + fi + + { + echo "owner_lc=$OWNER_LC" + echo "image_tag=$IMAGE_TAG" + echo "platforms=$PLATFORMS" + echo "tags<> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push appflowy_web + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/Dockerfile.ssr + platforms: ${{ steps.vars.outputs.platforms }} + push: true + provenance: false + tags: ${{ steps.vars.outputs.tags }} + build-args: | + VERSION=${{ steps.vars.outputs.image_tag }} + cache-from: type=gha,scope=ghcr-appflowy-web + cache-to: type=gha,mode=max,scope=ghcr-appflowy-web diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 62ec8ab04..1e9226a3b 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -24,7 +24,7 @@ jobs: name: "Build" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v4 @@ -98,7 +98,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v4 @@ -110,15 +110,47 @@ jobs: with: version: ${{ env.PNPM_VERSION }} + - name: Check required secrets for premium E2E + id: prereq + shell: bash + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} + CI_OPENAI_API_KEY: ${{ secrets.CI_OPENAI_API_KEY }} + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + run: | + missing=() + + [ -z "$CI_TOKEN" ] && missing+=("CI_TOKEN") + [ -z "$CI_OPENAI_API_KEY" ] && missing+=("CI_OPENAI_API_KEY") + [ -z "$DOCKER_HUB_USERNAME" ] && missing+=("DOCKER_HUB_USERNAME") + [ -z "$DOCKER_HUB_ACCESS_TOKEN" ] && missing+=("DOCKER_HUB_ACCESS_TOKEN") + + if [ ${#missing[@]} -gt 0 ]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "missing=${missing[*]}" >> "$GITHUB_OUTPUT" + { + echo "## Playwright E2E skipped" + echo "" + echo "Missing required secrets: ${missing[*]}" + echo "" + echo "Set these repository secrets to enable this workflow in your fork." + } >> "$GITHUB_STEP_SUMMARY" + else + echo "ready=true" >> "$GITHUB_OUTPUT" + fi + - name: Checkout AppFlowy-Cloud-Premium - uses: actions/checkout@v4 + if: steps.prereq.outputs.ready == 'true' + uses: actions/checkout@v5 with: repository: AppFlowy-IO/AppFlowy-Cloud-Premium ref: main - token: ${{ secrets.CI_TOKEN }} + token: ${{ secrets.CI_TOKEN || github.token }} path: AppFlowy-Cloud-Premium - name: Setup AppFlowy Cloud + if: steps.prereq.outputs.ready == 'true' working-directory: AppFlowy-Cloud-Premium env: OPENAI_KEY: ${{ secrets.CI_OPENAI_API_KEY }} @@ -133,6 +165,7 @@ jobs: echo 'APPFLOWY_PAGE_HISTORY_ENABLE=true' >> .env - name: Log in to Docker Hub + if: steps.prereq.outputs.ready == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} @@ -140,6 +173,7 @@ jobs: # Overlap Docker pull (network I/O) with pnpm install + Playwright browser install - name: Install deps and pull Docker images (parallel) + if: steps.prereq.outputs.ready == 'true' env: APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} APPFLOWY_AI_VERSION: ${{ env.CLOUD_VERSION }} @@ -156,6 +190,7 @@ jobs: wait $DOCKER_PID - name: Cache Playwright browsers + if: steps.prereq.outputs.ready == 'true' id: playwright-cache uses: actions/cache@v4 with: @@ -163,23 +198,26 @@ jobs: key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }} - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' + if: steps.prereq.outputs.ready == 'true' && steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm exec playwright install --with-deps chromium - name: Install Playwright deps (cached browsers) - if: steps.playwright-cache.outputs.cache-hit == 'true' + if: steps.prereq.outputs.ready == 'true' && steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm exec playwright install-deps chromium - name: Setup environment + if: steps.prereq.outputs.ready == 'true' run: cp deploy.env .env - name: Download build artifact + if: steps.prereq.outputs.ready == 'true' uses: actions/download-artifact@v4 with: name: build-dist path: dist/ - name: Start Docker services + if: steps.prereq.outputs.ready == 'true' working-directory: AppFlowy-Cloud-Premium env: APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }} @@ -203,14 +241,17 @@ jobs: ) - name: Setup Bun + if: steps.prereq.outputs.ready == 'true' uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install Bun SSR dependencies + if: steps.prereq.outputs.ready == 'true' run: bun install cheerio pino pino-pretty - name: Start SSR server + if: steps.prereq.outputs.ready == 'true' run: | pnpm run dev:server & echo $! > ssr-server.pid @@ -221,13 +262,14 @@ jobs: done' && echo "SSR server is ready" || (echo "SSR server failed to start" && exit 1) - name: Run ${{ matrix.test-group.name }} tests + if: steps.prereq.outputs.ready == 'true' run: pnpm exec playwright test --workers=2 ${{ matrix.test-group.spec }} env: CI: true BASE_URL: http://localhost:3000 - name: Test summary - if: always() + if: always() && steps.prereq.outputs.ready == 'true' run: | if [ ! -f playwright-report/report.json ]; then echo "## ${{ matrix.test-group.name }} — no report generated" >> $GITHUB_STEP_SUMMARY @@ -271,7 +313,7 @@ jobs: " >> $GITHUB_STEP_SUMMARY - name: Upload test results - if: always() + if: always() && steps.prereq.outputs.ready == 'true' uses: actions/upload-artifact@v4 with: name: playwright-report-${{ matrix.test-group.name }} @@ -280,7 +322,7 @@ jobs: retention-days: 7 - name: Upload test traces - if: failure() + if: failure() && steps.prereq.outputs.ready == 'true' uses: actions/upload-artifact@v4 with: name: playwright-traces-${{ matrix.test-group.name }} @@ -289,7 +331,7 @@ jobs: retention-days: 3 - name: Cleanup - if: always() + if: always() && steps.prereq.outputs.ready == 'true' run: | if [ -f ssr-server.pid ]; then kill $(cat ssr-server.pid) 2>/dev/null || true diff --git a/docker/docker-compose.example.yml b/docker/docker-compose.example.yml index c5b004c51..114861a56 100644 --- a/docker/docker-compose.example.yml +++ b/docker/docker-compose.example.yml @@ -2,7 +2,7 @@ version: '3.8' services: appflowy-web: - image: appflowy/appflowy_web:latest + image: ${APPFLOWY_WEB_IMAGE:-appflowyinc/appflowy_web}:${APPFLOWY_WEB_VERSION:-latest} ports: - "80:80" environment: @@ -11,4 +11,4 @@ services: - APPFLOWY_BASE_URL=https://your-backend.example.com - APPFLOWY_GOTRUE_BASE_URL=https://your-backend.example.com/gotrue - APPFLOWY_WS_BASE_URL=wss://your-backend.example.com/ws/v2 - restart: unless-stopped \ No newline at end of file + restart: unless-stopped diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 711f9f70a..230a4782a 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -3148,6 +3148,7 @@ "continueWithDiscord": "Continue with Discord", "continueWithApple": "Continue with Apple ", "continueWithSaml": "Continue with SSO", + "continueWithOidc": "Continue with OIDC", "continueWithSso": "Continue", "ssoLogin": "SSO Login", "ssoLoginDescription": "Enter your work email to sign in with your organization's identity provider.", diff --git a/src/application/services/domains/auth.ts b/src/application/services/domains/auth.ts index 3ecaf0c1f..1d35e574e 100644 --- a/src/application/services/domains/auth.ts +++ b/src/application/services/domains/auth.ts @@ -5,6 +5,7 @@ export { signInAppleWithRedirect as signInApple, signInGithubWithRedirect as signInGithub, signInDiscordWithRedirect as signInDiscord, + signInAuthentikWithRedirect as signInAuthentik, signInSamlWithRedirect as signInSaml, signInWithPasswordWithRedirect as signInWithPassword, signUpWithPasswordWithRedirect as signUpWithPassword, diff --git a/src/application/services/js-services/cached-api.ts b/src/application/services/js-services/cached-api.ts index b275331b5..67e07039c 100644 --- a/src/application/services/js-services/cached-api.ts +++ b/src/application/services/js-services/cached-api.ts @@ -46,6 +46,7 @@ import { changePassword, forgotPassword, signInApple, + signInAuthentik, signInDiscord, signInGithub, signInGoogle, @@ -461,6 +462,7 @@ export { signInApple, signInGithub, signInDiscord, + signInAuthentik, signInSaml, }; @@ -484,6 +486,11 @@ export async function signInDiscordWithRedirect(params: { redirectTo: string }) return signInDiscord(AUTH_CALLBACK_URL); } +export async function signInAuthentikWithRedirect(params: { redirectTo: string }) { + saveRedirectTo(params.redirectTo); + return signInAuthentik(AUTH_CALLBACK_URL); +} + export async function signInSamlWithRedirect(params: { redirectTo: string; domain: string }): Promise { saveRedirectTo(params.redirectTo); return signInSaml(AUTH_CALLBACK_URL, params.domain); diff --git a/src/application/services/js-services/http/auth-api.ts b/src/application/services/js-services/http/auth-api.ts index df27ceadd..6a479d538 100644 --- a/src/application/services/js-services/http/auth-api.ts +++ b/src/application/services/js-services/http/auth-api.ts @@ -1,7 +1,7 @@ import { AuthProvider } from '@/application/types'; import { Log } from '@/utils/log'; -import { refreshToken } from './gotrue'; +import { refreshToken, settings as getGoTrueSettings } from './gotrue'; import { parseGoTrueErrorFromUrl } from './gotrue-error'; import { APIError, APIResponse, executeAPIRequest, getAxios } from './core'; @@ -10,6 +10,45 @@ export interface ServerInfo { ai_enabled?: boolean; } +interface GoTrueSettingsPayload { + external?: Record; + saml_enabled?: boolean; +} + +function mapAuthProvider(provider: string): AuthProvider | null { + switch (provider.toLowerCase()) { + case 'google': + return AuthProvider.GOOGLE; + case 'apple': + return AuthProvider.APPLE; + case 'github': + return AuthProvider.GITHUB; + case 'discord': + return AuthProvider.DISCORD; + case 'authentik': + return AuthProvider.AUTHENTIK; + case 'email': + return AuthProvider.EMAIL; + case 'password': + return AuthProvider.PASSWORD; + case 'magic_link': + return AuthProvider.MAGIC_LINK; + case 'saml': + return AuthProvider.SAML; + case 'oidc': + return AuthProvider.OIDC; + case 'phone': + return AuthProvider.PHONE; + default: + console.warn(`Unknown auth provider from server: ${provider}`); + return null; + } +} + +function mapAuthProviders(providers: string[]): AuthProvider[] { + return providers.map(mapAuthProvider).filter((provider): provider is AuthProvider => provider !== null); +} + export async function signInWithUrl(url: string) { Log.info('[Auth] signInWithUrl: processing OAuth callback'); @@ -132,38 +171,35 @@ export async function getAuthProviders(): Promise { }>>(url) ); - return payload.providers - .map((provider: string) => { - switch (provider.toLowerCase()) { - case 'google': - return AuthProvider.GOOGLE; - case 'apple': - return AuthProvider.APPLE; - case 'github': - return AuthProvider.GITHUB; - case 'discord': - return AuthProvider.DISCORD; - case 'email': - return AuthProvider.EMAIL; - case 'password': - return AuthProvider.PASSWORD; - case 'magic_link': - return AuthProvider.MAGIC_LINK; - case 'saml': - return AuthProvider.SAML; - case 'phone': - return AuthProvider.PHONE; - default: - console.warn(`Unknown auth provider from server: ${provider}`); - return null; - } - }) - .filter((provider): provider is AuthProvider => provider !== null); + return mapAuthProviders(payload.providers); } catch (error) { const message = (error as APIError)?.message; console.warn('Auth providers API returned error:', message); console.error('Failed to fetch auth providers:', error); + + // Fallback for self-hosted deployments where /api/server-info/auth-providers + // is not available: read provider flags directly from GoTrue settings. + try { + const settings = (await getGoTrueSettings()) as GoTrueSettingsPayload; + const enabledExternalProviders = Object.entries(settings.external || {}) + .filter(([, enabled]) => enabled === true) + .map(([provider]) => provider); + + if (settings.saml_enabled) { + enabledExternalProviders.push('saml'); + } + + const uniqueProviders = Array.from(new Set(enabledExternalProviders)); + const mappedProviders = mapAuthProviders(uniqueProviders); + + if (mappedProviders.length > 0) { + return mappedProviders; + } + } catch (fallbackError) { + console.error('Failed to fetch auth providers from GoTrue settings fallback:', fallbackError); + } + return [AuthProvider.PASSWORD]; } } diff --git a/src/application/services/js-services/http/gotrue.ts b/src/application/services/js-services/http/gotrue.ts index d8ecc37e1..0f83f9ef1 100644 --- a/src/application/services/js-services/http/gotrue.ts +++ b/src/application/services/js-services/http/gotrue.ts @@ -481,6 +481,16 @@ export function signInDiscord(authUrl: string) { window.open(url, '_current'); } +export function signInAuthentik(authUrl: string) { + const provider = 'authentik'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + Log.info('[Auth] signInAuthentik: redirecting to Authentik OAuth'); + window.open(url, '_current'); +} + interface AxiosErrorLike { response?: { data?: { message?: string; msg?: string }; diff --git a/src/application/types.ts b/src/application/types.ts index 226fdfeb3..66bc8c397 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -1008,9 +1008,11 @@ export enum AuthProvider { APPLE = 'apple', GITHUB = 'github', DISCORD = 'discord', + AUTHENTIK = 'authentik', PASSWORD = 'password', MAGIC_LINK = 'magic_link', SAML = 'saml', + OIDC = 'oidc', PHONE = 'phone', EMAIL = 'email', } diff --git a/src/components/login/LoginProvider.tsx b/src/components/login/LoginProvider.tsx index 36d292606..a44ed85b7 100644 --- a/src/components/login/LoginProvider.tsx +++ b/src/components/login/LoginProvider.tsx @@ -68,11 +68,21 @@ function LoginProvider({ value: AuthProvider.DISCORD, Icon: DiscordSvg, }, + { + label: 'Continue with Authentik', + value: AuthProvider.AUTHENTIK, + Icon: SamlSvg, + }, { label: t('web.continueWithSaml'), value: AuthProvider.SAML, Icon: SamlSvg, }, + { + label: t('web.continueWithOidc'), + value: AuthProvider.OIDC, + Icon: SamlSvg, + }, ], [t] ); @@ -106,8 +116,12 @@ function LoginProvider({ case AuthProvider.DISCORD: await AuthService.signInDiscord({ redirectTo }); break; + case AuthProvider.AUTHENTIK: + await AuthService.signInAuthentik({ redirectTo }); + break; case AuthProvider.SAML: - // Open SAML dialog to get user's email for domain identification + case AuthProvider.OIDC: + // Open SAML/OIDC dialog to get user's email for domain identification setSamlDialogOpen(true); return; }