From b762af7f415b3f3da5f7de68e0e3428024c61c6f Mon Sep 17 00:00:00 2001 From: Nikhil Thorat Date: Thu, 20 Jul 2023 15:00:43 -0400 Subject: [PATCH] Add Google OAuth Login flow. (#459) Demo: https://huggingface.co/spaces/lilacai/nikhil_staging We use Google login to authenticate the user. This uses the OAuth2 flow where a user clicks /login, redirects to /auth which sets a cookie, and redirects back to the app. - Add a login button in the top-left. - When in an iframe, pop the user to new tab. This is necessary as we can't set the oauth2 cookie from inside the iframe. --- .env | 8 ++ README.md | 19 +++ mypy.ini | 4 + poetry.lock | 74 +++++++++- pyproject.toml | 92 +++++++------ src/auth.py | 17 +++ src/router_google_login.py | 62 +++++++++ src/server.py | 41 ++++-- web/blueprint/src/lib/components/Page.svelte | 38 ++++++ .../src/lib/components/TaskStatus.svelte | 129 +++++++++--------- .../contextMenu/SchemaFieldMenu.svelte | 8 +- .../lib/components/datasetView/Dataset.svelte | 6 +- .../src/lib/queries/googleAuthQueries.ts | 8 ++ .../src/lib/queries/serverQueries.ts | 3 +- .../src/lib/stores/TaskMonitor.svelte | 7 +- .../src/routes/concepts/+page.svelte | 6 +- .../src/routes/datasets/Datasets.svelte | 8 +- web/blueprint/vite.config.ts | 3 + web/lib/fastapi_client/index.ts | 3 + .../models/AuthenticationInfo.ts | 16 +++ web/lib/fastapi_client/models/UserInfo.ts | 14 ++ .../fastapi_client/services/DefaultService.ts | 10 +- .../services/GoogleLoginService.ts | 58 ++++++++ 23 files changed, 494 insertions(+), 140 deletions(-) create mode 100644 src/router_google_login.py create mode 100644 web/blueprint/src/lib/queries/googleAuthQueries.ts create mode 100644 web/lib/fastapi_client/models/AuthenticationInfo.ts create mode 100644 web/lib/fastapi_client/models/UserInfo.ts create mode 100644 web/lib/fastapi_client/services/GoogleLoginService.ts diff --git a/.env b/.env index 00aee7999..3ea43c681 100644 --- a/.env +++ b/.env @@ -30,3 +30,11 @@ DUCKDB_USE_VIEWS=0 # HF_USERNAME= # The default repo to deploy to for a staging demo. Can be overridden by a command line flag. # HF_STAGING_DEMO_REPO='HF_ORG/HF_REPO_NAME' + +# For Google-login. This is generated from the Google Cloud Console for a web client. +# See: https://developers.google.com/identity/protocols/oauth2 +GOOGLE_CLIENT_ID='279475920249-i8llm8vbos1vj5m1qocir8narb3r0enu.apps.googleusercontent.com' +# The client secret of the above client. +# GOOGLE_CLIENT_SECRET= +# A random string for oauth sessions. +# LILAC_OAUTH_SECRET_KEY= diff --git a/README.md b/README.md index 943a5caeb..a3b9bb313 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,25 @@ To run the docker image locally: docker run -p 5432:5432 lilac_blueprint ``` +#### Authentication + +Authentication is done via Google login. A Google Client token should be created +from the Google API Console. Details can be found [here](https://developers.google.com/identity/protocols/oauth2). + +By default, the Lilac google client is used. The secret can be found in Google +Cloud console, and should be defined under `GOOGLE_CLIENT_SECRET` in .env.local. + +For the session middleware, a random string should be created and defined as `LILAC_OAUTH_SECRET_KEY` in .env.local. + +You can generate a random secret key with: + +```py +import string +import random +key = ''.join(random.choices(string.ascii_uppercase + string.digits, k=64)) +print(f"LILAC_OAUTH_SECRET_KEY='{key}'") +``` + ### Configuration To use various API's, API keys need to be provided. Create a file named `.env.local` in the root, and add variables that are listed in `.env` with your own values. diff --git a/mypy.ini b/mypy.ini index 9c4f87d86..a49c4e65e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -116,3 +116,7 @@ follow_imports = skip [mypy-langdetect.*] ignore_missing_imports = True follow_imports = skip + +[mypy-authlib.*] +ignore_missing_imports = True +follow_imports = skip diff --git a/poetry.lock b/poetry.lock index c7d3cee4f..5d01516be 100644 --- a/poetry.lock +++ b/poetry.lock @@ -270,6 +270,20 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib- tests = ["attrs[tests-no-zope]", "zope-interface"] tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +[[package]] +name = "authlib" +version = "1.2.1" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = "*" +files = [ + {file = "Authlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:c88984ea00149a90e3537c964327da930779afa4564e354edfd98410bea01911"}, + {file = "Authlib-1.2.1.tar.gz", hash = "sha256:421f7c6b468d907ca2d9afede256f068f87e34d23dd221c07d13d4c234726afb"}, +] + +[package.dependencies] +cryptography = ">=3.2" + [[package]] name = "backcall" version = "0.2.0" @@ -787,6 +801,51 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "41.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"}, + {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"}, + {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"}, + {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"}, + {file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"}, + {file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"}, + {file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "cycler" version = "0.11.0" @@ -2252,6 +2311,17 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib" plugins = ["setuptools"] requirements-deprecated-finder = ["pip-api", "pipreqs"] +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + [[package]] name = "jedi" version = "0.18.2" @@ -6290,11 +6360,11 @@ gmail = ["email-reply-parser", "google-api-python-client", "google-auth-httplib2 lang-detection = ["langdetect"] llms = ["openai"] ner = ["spacy"] -pii = ["detect-secrets"] +pii = ["detect-secrets", "regex"] signals = ["detect-secrets", "langdetect", "spacy", "textacy"] text-stats = ["spacy", "textacy"] [metadata] lock-version = "2.0" python-versions = "~3.9" -content-hash = "aad4322f631f6e40a037c9efea62e5221814e8dd67ef13ac59c4a3605c36a526" +content-hash = "2c40fa7f07421692189813eeb6c8bc0bf6aaccd077b1898279c44a74ad0b815e" diff --git a/pyproject.toml b/pyproject.toml index 940375df3..8322929a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,30 +9,33 @@ version = "0.0.1" [tool.poetry.dependencies] ### Required dependencies. ### -dask = "^2023.3.2" -datasets = "^2.12.0" -distributed = "^2023.3.2.1" -duckdb = "^0.8.1" -fastapi = "^0.98.0" -gcsfs = "^2023.4.0" -google-cloud-storage = "^2.5.0" -gunicorn = "^20.1.0" -joblib = "^1.3.1" +authlib = "^1.2.1" +dask = "^2023.3.2" +datasets = "^2.12.0" +distributed = "^2023.3.2.1" +duckdb = "^0.8.1" +fastapi = "^0.98.0" +gcsfs = "^2023.4.0" +google-cloud-storage = "^2.5.0" +gunicorn = "^20.1.0" +httpx = "^0.24.1" +itsdangerous = "^2.1.2" +joblib = "^1.3.1" openai-function-call = "^0.0.5" # Wraps OpenAI functions with Pydantic models. orjson = "^3.8.10" # Fast JSON serialization: https://fastapi.tiangolo.com/advanced/custom-response/#use-orjsonresponse pillow = "^9.3.0" # Image processing. -psutil = "^5.9.5" -pyarrow = "^9.0.0" -pydantic = "^1.10.11" -python = "~3.9" -python-dotenv = "^1.0.0" -requests = "^2.28.1" -scikit-learn = "^1.3.0" -tenacity = "^8.2.2" -tqdm = "^4.65.0" -types-psutil = "^5.9.5.12" -typing-extensions = "^4.7.1" -uvicorn = {extras = ["standard"], version = "^0.22.0"} +psutil = "^5.9.5" +pyarrow = "^9.0.0" +pydantic = "^1.10.11" +python = "~3.9" +python-dotenv = "^1.0.0" +requests = "^2.28.1" +scikit-learn = "^1.3.0" +tenacity = "^8.2.2" +tqdm = "^4.65.0" +types-psutil = "^5.9.5.12" +typing-extensions = "^4.7.1" +uvicorn = {extras = ["standard"], version = "^0.22.0"} ### Optional dependencies. ### @@ -61,6 +64,7 @@ regex = "^2023.6.3" # For language detection. langdetect = {version = "^1.0.9", optional = true} + [tool.poetry.extras] all = [ "cohere", @@ -97,29 +101,29 @@ optional = true [tool.poetry.group.dev.dependencies] bokeh = ">=2.4.2,<3" # Required for Dask monitoring. -click = "^8.1.3" -google-api-python-client-stubs = "^1.13.0" -httpx = "^0.24.0" -huggingface-hub = "^0.15.1" -isort = "^5.12.0" -matplotlib = "^3.7.1" -mypy = "^1.0.0" -notebook = "^6.5.4" -pytest = "^7.1.3" -pytest-asyncio = "^0.20.2" -pytest-cov = "^4.0.0" -pytest-mock = "^3.10.0" -ruff = "^0.0.219" -setuptools = "^65.5.0" -toml = "^0.10.2" -types-Pillow = "^9.3.0.4" -types-cachetools = "^5.3.0.5" -types-regex = "^2023.6.3.0" -types-requests = "^2.28.11.5" -types-tqdm = "^4.65.0.0" -watchdog = {extras = ["watchmedo"], version = "^3.0.0"} -wheel = "^0.37.1" -yapf = "^0.32.0" +click = "^8.1.3" +google-api-python-client-stubs = "^1.13.0" +httpx = "^0.24.0" +huggingface-hub = "^0.15.1" +isort = "^5.12.0" +matplotlib = "^3.7.1" +mypy = "^1.0.0" +notebook = "^6.5.4" +pytest = "^7.1.3" +pytest-asyncio = "^0.20.2" +pytest-cov = "^4.0.0" +pytest-mock = "^3.10.0" +ruff = "^0.0.219" +setuptools = "^65.5.0" +toml = "^0.10.2" +types-Pillow = "^9.3.0.4" +types-cachetools = "^5.3.0.5" +types-regex = "^2023.6.3.0" +types-requests = "^2.28.11.5" +types-tqdm = "^4.65.0.0" +watchdog = {extras = ["watchmedo"], version = "^3.0.0"} +wheel = "^0.37.1" +yapf = "^0.32.0" [tool.poetry.scripts] deploy-hf = "scripts.deploy_hf:main" diff --git a/src/auth.py b/src/auth.py index e235b7925..faecbd543 100644 --- a/src/auth.py +++ b/src/auth.py @@ -1,5 +1,7 @@ """Authentication and ACL configuration.""" +from typing import Optional + from pydantic import BaseModel from .config import CONFIG @@ -32,6 +34,21 @@ class UserAccess(BaseModel): concept: ConceptUserAccess +class UserInfo(BaseModel): + """User information.""" + email: str + name: str + given_name: str + family_name: str + + +class AuthenticationInfo(BaseModel): + """Authentication information for the user.""" + user: Optional[UserInfo] + access: UserAccess + auth_enabled: bool + + def get_user_access() -> UserAccess: """Get the user access.""" auth_enabled = CONFIG.get('LILAC_AUTH_ENABLED', False) diff --git a/src/router_google_login.py b/src/router_google_login.py new file mode 100644 index 000000000..9da9bf354 --- /dev/null +++ b/src/router_google_login.py @@ -0,0 +1,62 @@ +"""Router for Google OAuth2 login.""" + +from urllib.parse import urlparse, urlunparse + +from authlib.integrations.starlette_client import OAuth, OAuthError +from fastapi import APIRouter, Request, Response +from fastapi.responses import HTMLResponse +from starlette.config import Config +from starlette.responses import RedirectResponse + +from .config import CONFIG +from .router_utils import RouteErrorHandler + +router = APIRouter(route_class=RouteErrorHandler) + +GOOGLE_CLIENT_ID = CONFIG.get('GOOGLE_CLIENT_ID', None) +GOOGLE_CLIENT_SECRET = CONFIG.get('GOOGLE_CLIENT_SECRET', None) +LILAC_AUTH_ENABLED = CONFIG.get('LILAC_AUTH_ENABLED', False) +if LILAC_AUTH_ENABLED: + if GOOGLE_CLIENT_ID is None or GOOGLE_CLIENT_SECRET is None: + raise ValueError( + 'Missing `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` when `LILAC_AUTH_ENABLED=true`') + SECRET_KEY = CONFIG.get('LILAC_OAUTH_SECRET_KEY', None) + if not SECRET_KEY: + raise ValueError('Missing `LILAC_OAUTH_SECRET_KEY` when `LILAC_AUTH_ENABLED=true`') + + # Set up oauth + oauth = OAuth( + Config(environ={ + 'GOOGLE_CLIENT_ID': GOOGLE_CLIENT_ID, + 'GOOGLE_CLIENT_SECRET': GOOGLE_CLIENT_SECRET + })) + oauth.register( + name='google', + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={'scope': 'openid email profile'}, + ) + + +@router.get('/login') +async def login(request: Request, origin_url: str) -> RedirectResponse: + """Redirects to Google OAuth login page.""" + auth_path = urlunparse(urlparse(origin_url)._replace(path='/google/auth')) + return await oauth.google.authorize_redirect(request, auth_path) + + +@router.get('/auth') +async def auth(request: Request) -> Response: + """Handles the Google OAuth callback.""" + try: + token = await oauth.google.authorize_access_token(request) + except OAuthError as error: + return HTMLResponse(f'

{error}

') + request.session['user'] = token['userinfo'] + return RedirectResponse(url='/') + + +@router.get('/logout') +def logout(request: Request) -> RedirectResponse: + """Logs the user out.""" + request.session.pop('user', None) + return RedirectResponse(url='/') diff --git a/src/server.py b/src/server.py index c7f3aba61..3525b5c2a 100644 --- a/src/server.py +++ b/src/server.py @@ -4,15 +4,23 @@ import os import shutil import subprocess -from typing import Any +from typing import Any, Optional -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, FastAPI, Request from fastapi.responses import FileResponse, ORJSONResponse from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles - -from . import router_concept, router_data_loader, router_dataset, router_signal, router_tasks -from .auth import UserAccess, get_user_access +from starlette.middleware.sessions import SessionMiddleware + +from . import ( + router_concept, + router_data_loader, + router_dataset, + router_google_login, + router_signal, + router_tasks, +) +from .auth import AuthenticationInfo, UserInfo, get_user_access from .concepts.db_concept import DiskConceptDB, get_concept_output_dir from .config import CONFIG, data_path from .router_utils import RouteErrorHandler @@ -20,6 +28,10 @@ from .utils import get_dataset_output_dir, list_datasets DIST_PATH = os.path.abspath(os.path.join('web', 'blueprint', 'build')) +LILAC_AUTH_ENABLED = CONFIG.get('LILAC_AUTH_ENABLED', False) +LILAC_OAUTH_SECRET_KEY = CONFIG.get('LILAC_OAUTH_SECRET_KEY', None) +if LILAC_AUTH_ENABLED and not LILAC_OAUTH_SECRET_KEY: + raise ValueError('`LILAC_OAUTH_SECRET_KEY` must be set if `LILAC_AUTH_ENABLED` is True.') tags_metadata: list[dict[str, Any]] = [{ 'name': 'datasets', @@ -45,6 +57,8 @@ def custom_generate_unique_id(route: APIRoute) -> str: default_response_class=ORJSONResponse, generate_unique_id_function=custom_generate_unique_id, openapi_tags=tags_metadata) +app.add_middleware(SessionMiddleware, secret_key=LILAC_OAUTH_SECRET_KEY) +app.include_router(router_google_login.router, prefix='/google', tags=['google_login']) v1_router = APIRouter(route_class=RouteErrorHandler) v1_router.include_router(router_dataset.router, prefix='/datasets', tags=['datasets']) @@ -54,13 +68,24 @@ def custom_generate_unique_id(route: APIRoute) -> str: v1_router.include_router(router_tasks.router, prefix='/tasks', tags=['tasks']) -@v1_router.get('/acl') -def user_acls() -> UserAccess: +@app.get('/auth_info') +def auth_info(request: Request) -> AuthenticationInfo: """Returns the user's ACLs. NOTE: Validation happens server-side as well. This is just used for UI treatment. """ - return get_user_access() + user_info: Optional[UserInfo] = None + if LILAC_AUTH_ENABLED: + session_user = request.session.get('user', None) + if session_user: + user_info = UserInfo( + email=session_user['email'], + name=session_user['name'], + given_name=session_user['given_name'], + family_name=session_user['family_name']) + + return AuthenticationInfo( + user=user_info, access=get_user_access(), auth_enabled=LILAC_AUTH_ENABLED) app.include_router(v1_router, prefix='/api/v1') diff --git a/web/blueprint/src/lib/components/Page.svelte b/web/blueprint/src/lib/components/Page.svelte index f161a59ae..f946d681a 100644 --- a/web/blueprint/src/lib/components/Page.svelte +++ b/web/blueprint/src/lib/components/Page.svelte @@ -1,10 +1,27 @@
@@ -27,6 +44,27 @@
+ {#if $authInfo.data?.auth_enabled} + {#if $authInfo.data?.user != null} +
+
+ {$authInfo.data?.user.given_name} +
+
+ + + +
+
+ {:else} + + {/if} + {/if}
diff --git a/web/blueprint/src/lib/components/TaskStatus.svelte b/web/blueprint/src/lib/components/TaskStatus.svelte index 7e058fac3..2809a9ff7 100644 --- a/web/blueprint/src/lib/components/TaskStatus.svelte +++ b/web/blueprint/src/lib/components/TaskStatus.svelte @@ -1,5 +1,5 @@ -
- -
+ {/each} + + + + +{/if}