mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge remote-tracking branch 'origin/main' into fix-ssr-start-command-sites
# Conflicts: # src/routes/(console)/project-[region]-[project]/settings/updateProtocols.svelte
This commit is contained in:
@@ -6,12 +6,20 @@
|
||||
"name": "@appwrite/console",
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@f063676",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@93d2dfa",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8dcaa17",
|
||||
"@codemirror/autocomplete": "^6.19.0",
|
||||
"@codemirror/commands": "^6.9.0",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/lint": "^6.9.0",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.55.1",
|
||||
@@ -20,12 +28,14 @@
|
||||
"@threlte/extras": "^9.13.0",
|
||||
"ai": "^6.0.138",
|
||||
"analytics": "^0.8.19",
|
||||
"codemirror-json5": "^1.0.3",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"deep-equal": "^2.2.3",
|
||||
"echarts": "^5.6.0",
|
||||
"flatted": "^3.4.2",
|
||||
"ignore": "^6.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"nanoid": "^5.1.7",
|
||||
"nanotar": "^0.3.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
@@ -37,6 +47,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
"@playwright/test": "^1.58.2",
|
||||
@@ -82,7 +93,7 @@
|
||||
"flatted": "^3.4.2",
|
||||
"immutable": "^5.1.5",
|
||||
"minimatch": "10.2.3",
|
||||
"picomatch": "^2.3.2",
|
||||
"picomatch": "^4.0.4",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"yaml": "^1.10.3",
|
||||
},
|
||||
@@ -113,7 +124,7 @@
|
||||
|
||||
"@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="],
|
||||
|
||||
"@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@f063676", { "dependencies": { "json-bigint": "1.0.0" } }],
|
||||
"@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@93d2dfa", { "dependencies": { "json-bigint": "1.0.0" } }],
|
||||
|
||||
"@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="],
|
||||
|
||||
@@ -159,6 +170,20 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
|
||||
|
||||
"@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="],
|
||||
|
||||
"@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="],
|
||||
|
||||
"@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
|
||||
@@ -225,6 +250,14 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
|
||||
|
||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||
|
||||
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
|
||||
|
||||
"@melt-ui/pp": ["@melt-ui/pp@0.3.2", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.5" }, "peerDependencies": { "@melt-ui/svelte": ">= 0.29.0", "svelte": "^3.55.0 || ^4.0.0 || ^5.0.0-next.1" } }, "sha512-xKkPvaIAFinklLXcQOpwZ8YSpqAFxykjWf8Y/fSJQwsixV/0rcFs07hJ49hJjPy5vItvw5Qa0uOjzFUbXzBypQ=="],
|
||||
|
||||
"@melt-ui/svelte": ["@melt-ui/svelte@0.86.6", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118" } }, "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg=="],
|
||||
@@ -647,6 +680,8 @@
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"codemirror-json5": ["codemirror-json5@1.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "json5": "^2.2.1", "lezer-json5": "^2.0.2" } }, "sha512-HmmoYO2huQxoaoG5ARKjqQc9mz7/qmNPvMbISVfIE2Gk1+4vZQg9X3G6g49MYM5IK00Ol3aijd7OKrySuOkA7Q=="],
|
||||
|
||||
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
|
||||
|
||||
"color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
|
||||
@@ -663,6 +698,8 @@
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
|
||||
|
||||
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
@@ -1009,6 +1046,8 @@
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lezer-json5": ["lezer-json5@2.0.2", "", { "dependencies": { "@lezer/lr": "^1.0.0" } }, "sha512-NRmtBlKW/f8mA7xatKq8IUOq045t8GVHI4kZXrUtYYUdiVeGiO6zKGAV7/nUAnf5q+rYTY+SWX/gvQdFXMjNxQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -1145,7 +1184,7 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
@@ -1289,6 +1328,8 @@
|
||||
|
||||
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||
|
||||
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||
|
||||
"style-value-types": ["style-value-types@5.1.2", "", { "dependencies": { "hey-listen": "^1.0.8", "tslib": "2.4.0" } }, "sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
@@ -1409,6 +1450,8 @@
|
||||
|
||||
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export default ts.config(
|
||||
// TODO: @itznotabug, this requires a big refactor!
|
||||
'svelte/no-navigation-without-resolve': 'warn',
|
||||
'svelte/prefer-svelte-reactivity': 'warn',
|
||||
'svelte/prefer-writable-derived': 'warn'
|
||||
'svelte/prefer-writable-derived': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+13
-2
@@ -20,12 +20,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@f063676",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@93d2dfa",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8dcaa17",
|
||||
"@codemirror/autocomplete": "^6.19.0",
|
||||
"@codemirror/commands": "^6.9.0",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/lint": "^6.9.0",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.55.1",
|
||||
@@ -34,12 +42,14 @@
|
||||
"@threlte/extras": "^9.13.0",
|
||||
"ai": "^6.0.138",
|
||||
"analytics": "^0.8.19",
|
||||
"codemirror-json5": "^1.0.3",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"deep-equal": "^2.2.3",
|
||||
"echarts": "^5.6.0",
|
||||
"flatted": "^3.4.2",
|
||||
"ignore": "^6.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"nanoid": "^5.1.7",
|
||||
"nanotar": "^0.3.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
@@ -51,6 +61,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
"@playwright/test": "^1.58.2",
|
||||
@@ -95,7 +106,7 @@
|
||||
"immutable": "^5.1.5",
|
||||
"flatted": "^3.4.2",
|
||||
"yaml": "^1.10.3",
|
||||
"picomatch": "^2.3.2",
|
||||
"picomatch": "^4.0.4",
|
||||
"cookie": "^0.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ export enum Click {
|
||||
DatabaseDatabaseDelete = 'click_database_delete',
|
||||
DatabaseImportCsv = 'click_database_import_csv',
|
||||
DatabaseExportCsv = 'click_database_export_csv',
|
||||
DatabaseImportJson = 'click_database_import_json',
|
||||
DomainCreateClick = 'click_domain_create',
|
||||
DomainDeleteClick = 'click_domain_delete',
|
||||
DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification',
|
||||
@@ -284,6 +285,7 @@ export enum Submit {
|
||||
DatabaseUpdateName = 'submit_database_update_name',
|
||||
DatabaseImportCsv = 'submit_database_import_csv',
|
||||
DatabaseExportCsv = 'submit_database_export_csv',
|
||||
DatabaseImportJSON = 'submit_database_import_json',
|
||||
DatabaseBackupDelete = 'submit_database_backup_delete',
|
||||
DatabaseBackupPolicyCreate = 'submit_database_backup_policy_create',
|
||||
|
||||
@@ -297,6 +299,11 @@ export enum Submit {
|
||||
RowUpdate = 'submit_row_update',
|
||||
RowUpdatePermissions = 'submit_row_update_permissions',
|
||||
|
||||
DocumentCreate = 'submit_document_create',
|
||||
DocumentDelete = 'submit_document_delete',
|
||||
DocumentUpdate = 'submit_document_update',
|
||||
DocumentUpdatePermissions = 'submit_document_update_permissions',
|
||||
|
||||
IndexCreate = 'submit_index_create',
|
||||
IndexDelete = 'submit_index_delete',
|
||||
|
||||
@@ -308,6 +315,14 @@ export enum Submit {
|
||||
TableUpdateEnabled = 'submit_table_update_enabled',
|
||||
TableUpdateDisplayNames = 'submit_table_update_display_names',
|
||||
|
||||
CollectionCreate = 'submit_collection_create',
|
||||
CollectionDelete = 'submit_collection_delete',
|
||||
CollectionUpdateName = 'submit_collection_update_name',
|
||||
CollectionUpdatePermissions = 'submit_collection_update_permissions',
|
||||
CollectionUpdateSecurity = 'submit_collection_update_security',
|
||||
CollectionUpdateEnabled = 'submit_collection_update_enabled',
|
||||
CollectionUpdateDisplayNames = 'submit_collection_update_display_names',
|
||||
|
||||
FunctionCreate = 'submit_function_create',
|
||||
FunctionDelete = 'submit_function_delete',
|
||||
FunctionUpdateName = 'submit_function_update_name',
|
||||
@@ -321,6 +336,7 @@ export enum Submit {
|
||||
FunctionUpdateScopes = 'submit_function_key_update_scopes',
|
||||
FunctionUpdateRuntime = 'submit_function_update_runtime',
|
||||
FunctionUpdateBuildCommand = 'submit_function_update_build_command',
|
||||
FunctionUpdateDeploymentRetention = 'submit_function_update_deployment_retention',
|
||||
FunctionConnectRepo = 'submit_function_connect_repo',
|
||||
FunctionDisconnectRepo = 'submit_function_disconnect_repo',
|
||||
FunctionRedeploy = 'submit_function_redeploy',
|
||||
@@ -443,6 +459,7 @@ export enum Submit {
|
||||
SiteUpdateScopes = 'submit_site_key_update_scopes',
|
||||
SiteUpdateBuildSettings = 'submit_site_update_build_settings',
|
||||
SiteUpdateResourceLimits = 'submit_site_update_resource_limits',
|
||||
SiteUpdateDeploymentRetention = 'submit_site_update_deployment_retention',
|
||||
SiteUpdateSinglePageApplication = 'submit_site_update_single_page_application',
|
||||
SiteConnectRepo = 'submit_site_connect_repo',
|
||||
SiteRedeploy = 'submit_site_redeploy',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { building } from '$app/environment';
|
||||
|
||||
const KEY_TARGET_USER_ID = 'console.impersonation.targetUserId';
|
||||
const KEY_OPERATOR = 'console.impersonation.operator';
|
||||
const KEY_TARGET = 'console.impersonation.target';
|
||||
|
||||
export type OperatorSnapshot = {
|
||||
$id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type TargetSnapshot = {
|
||||
$id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Incrementing revision triggers reactive re-fetches after impersonation changes.
|
||||
* Consumers can depend() on this or subscribe to it.
|
||||
*/
|
||||
export const impersonationRevision = writable(0);
|
||||
|
||||
function bumpRevision() {
|
||||
impersonationRevision.update((n) => n + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* All SDK clients that need impersonation headers applied/cleared.
|
||||
* Populated lazily by sdk.ts via registerImpersonationClients().
|
||||
*/
|
||||
let _clients: import('@appwrite.io/console').Client[] = [];
|
||||
|
||||
export function registerImpersonationClients(
|
||||
clients: import('@appwrite.io/console').Client[]
|
||||
): void {
|
||||
_clients = clients;
|
||||
}
|
||||
|
||||
export function applyImpersonation(targetUserId: string): void {
|
||||
for (const client of _clients) {
|
||||
client.setImpersonateUserId(targetUserId);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearImpersonation(): void {
|
||||
for (const client of _clients) {
|
||||
// Use the typed setters to empty the values so the SDK removes the headers cleanly.
|
||||
client.setImpersonateUserId('');
|
||||
client.setImpersonateUserEmail('');
|
||||
client.setImpersonateUserPhone('');
|
||||
}
|
||||
}
|
||||
|
||||
export function persistImpersonation(target: TargetSnapshot, operator: OperatorSnapshot): void {
|
||||
sessionStorage.setItem(KEY_TARGET_USER_ID, target.$id);
|
||||
sessionStorage.setItem(KEY_TARGET, JSON.stringify(target));
|
||||
sessionStorage.setItem(KEY_OPERATOR, JSON.stringify(operator));
|
||||
}
|
||||
|
||||
export function clearPersistedImpersonation(): void {
|
||||
sessionStorage.removeItem(KEY_TARGET_USER_ID);
|
||||
sessionStorage.removeItem(KEY_TARGET);
|
||||
sessionStorage.removeItem(KEY_OPERATOR);
|
||||
}
|
||||
|
||||
export function readTargetSnapshot(): TargetSnapshot | null {
|
||||
if (building) return null;
|
||||
const raw = sessionStorage.getItem(KEY_TARGET);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as TargetSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readImpersonationTargetUserId(): string | null {
|
||||
if (building) return null;
|
||||
return sessionStorage.getItem(KEY_TARGET_USER_ID);
|
||||
}
|
||||
|
||||
export function readOperatorSnapshot(): OperatorSnapshot | null {
|
||||
if (building) return null;
|
||||
const raw = sessionStorage.getItem(KEY_OPERATOR);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as OperatorSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once on app startup (e.g. root layout onMount).
|
||||
* Re-applies impersonation headers from sessionStorage to all registered clients.
|
||||
*/
|
||||
export function restoreImpersonation(): void {
|
||||
const targetUserId = readImpersonationTargetUserId();
|
||||
if (targetUserId) {
|
||||
applyImpersonation(targetUserId);
|
||||
bumpRevision();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start impersonating a user. Applies headers, persists to sessionStorage,
|
||||
* and bumps the revision store so callers can invalidate/refetch.
|
||||
*/
|
||||
export function startImpersonation(target: TargetSnapshot, operator: OperatorSnapshot): void {
|
||||
applyImpersonation(target.$id);
|
||||
persistImpersonation(target, operator);
|
||||
bumpRevision();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop impersonating. Clears headers, removes sessionStorage, bumps revision.
|
||||
*/
|
||||
export function stopImpersonation(): void {
|
||||
clearImpersonation();
|
||||
clearPersistedImpersonation();
|
||||
bumpRevision();
|
||||
}
|
||||
@@ -29,9 +29,11 @@ const groups = [
|
||||
'migrations',
|
||||
'users',
|
||||
'tables',
|
||||
'collections',
|
||||
'columns',
|
||||
'indexes',
|
||||
'rows',
|
||||
'documents',
|
||||
'teams',
|
||||
'security',
|
||||
'buckets',
|
||||
@@ -102,8 +104,19 @@ const commandsEnabled = derived(disabledMap, ($disabledMap) => {
|
||||
|
||||
export function isTargetInputLike(element: EventTarget | null) {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
|
||||
return !!element.closest(
|
||||
'input,textarea,select,[contenteditable],[role="combobox"],[role="textbox"],[role="searchbox"],[data-command-center-ignore]'
|
||||
[
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'[contenteditable]',
|
||||
'[role="combobox"]',
|
||||
'[role="textbox"]',
|
||||
'[role="searchbox"]',
|
||||
'[data-command-center-ignore]',
|
||||
'.cm-editor'
|
||||
].join(',')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
Layout,
|
||||
Popover,
|
||||
Selector,
|
||||
Typography
|
||||
Typography,
|
||||
Icon
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
|
||||
let {
|
||||
@@ -21,7 +23,8 @@
|
||||
allowNoColumns = false,
|
||||
showAnyway = false,
|
||||
children,
|
||||
onPreferencesUpdated = null
|
||||
onPreferencesUpdated = null,
|
||||
onCustomOptionClick = null
|
||||
}: {
|
||||
columns: Writable<Column[]>;
|
||||
isCustomTable?: boolean;
|
||||
@@ -30,6 +33,7 @@
|
||||
showAnyway?: boolean;
|
||||
children: Snippet<[toggle: () => void, selectedColumnsNumber: number]>;
|
||||
onPreferencesUpdated?: () => void;
|
||||
onCustomOptionClick?: () => void;
|
||||
} = $props();
|
||||
|
||||
let search = $state('');
|
||||
@@ -115,7 +119,7 @@
|
||||
cols.map((col) =>
|
||||
col.exclude
|
||||
? col
|
||||
: filteredColumns.some((fc) => fc.id === col.id)
|
||||
: filteredColumns.some((fc) => fc.id === col.id && !col.disable)
|
||||
? { ...col, hide: false }
|
||||
: col
|
||||
)
|
||||
@@ -126,7 +130,9 @@
|
||||
function deselectAll() {
|
||||
columns.update((cols) => {
|
||||
const realColumns = cols.filter((col) => !col.exclude && !col.isAction);
|
||||
const filtered = filteredColumns.filter((col) => !col.exclude && !col.isAction);
|
||||
const filtered = filteredColumns.filter(
|
||||
(col) => !col.exclude && !col.isAction && !col.disable
|
||||
);
|
||||
|
||||
if (filtered.length === 0) return cols;
|
||||
|
||||
@@ -187,7 +193,7 @@
|
||||
{@const placement = isNewStyle ? 'bottom-start' : 'bottom-end'}
|
||||
<Popover let:toggle {placement} padding="none">
|
||||
{@render children(toggle, selectedColumnsNumber)}
|
||||
<svelte:fragment slot="tooltip">
|
||||
<svelte:fragment slot="tooltip" let:toggle>
|
||||
<div bind:this={containerRef} class="actions-menu-wrapper" style:max-height={maxHeight}>
|
||||
<ActionMenu.Root>
|
||||
{#if isNewStyle && showActions}
|
||||
@@ -231,7 +237,8 @@
|
||||
on:click={() => toggleColumn(column)}
|
||||
disabled={allowNoColumns
|
||||
? false
|
||||
: visibleRealColumns.length <= 1 && !column.hide}>
|
||||
: (visibleRealColumns.length <= 1 && !column.hide) ||
|
||||
column.disable}>
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
@@ -243,6 +250,26 @@
|
||||
{/if}
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if onCustomOptionClick && isCustomTable}
|
||||
<Divider />
|
||||
<Layout.Stack gap="s" direction="row" style="padding-block-start: 0.175rem">
|
||||
<Button
|
||||
text
|
||||
size="s"
|
||||
fullWidth
|
||||
on:click={() => {
|
||||
toggle();
|
||||
onCustomOptionClick();
|
||||
}}>
|
||||
<Layout.Stack direction="row" gap="s" alignItems="center">
|
||||
<Icon icon={IconPlus} size="s" />
|
||||
|
||||
Custom
|
||||
</Layout.Stack>
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</ActionMenu.Root>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
// re-render the key for sheet UI.
|
||||
import { hash } from '$lib/helpers/string';
|
||||
import { spreadsheetRenderKey } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
|
||||
import { spreadsheetRenderKey } from '$database/store';
|
||||
import { Link } from '$lib/elements';
|
||||
|
||||
type CsvImportError = {
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
id = $bindable(null),
|
||||
autofocus = true,
|
||||
isProject = false,
|
||||
required = true
|
||||
required = true,
|
||||
syncFrom = undefined,
|
||||
disabled = false
|
||||
}: {
|
||||
show: boolean;
|
||||
name: string;
|
||||
@@ -20,8 +22,29 @@
|
||||
autofocus?: boolean;
|
||||
isProject?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
syncFrom?: string | undefined;
|
||||
} = $props();
|
||||
|
||||
let touchedId = $state(false);
|
||||
|
||||
function toIdFormat(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\-_. ]+/g, '')
|
||||
.replace(/ /g, '_')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/\.+$/, '')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.slice(0, 36); // max length
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (!touchedId) {
|
||||
touchedId = true;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
id = null;
|
||||
@@ -37,6 +60,21 @@
|
||||
trackEvent(Click.ShowCustomIdClick);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (syncFrom && !touchedId) {
|
||||
const newId = toIdFormat(syncFrom);
|
||||
if (id !== newId) {
|
||||
id = newId;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
touchedId = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
@@ -61,9 +99,9 @@
|
||||
<Divider />
|
||||
</span>
|
||||
{#if isProject}
|
||||
<InputProjectId on:input bind:value={id} {autofocus} />
|
||||
<InputProjectId {disabled} on:input={handleInput} bind:value={id} {autofocus} />
|
||||
{:else}
|
||||
<InputId {required} on:input bind:value={id} {autofocus} />
|
||||
<InputId {disabled} {required} on:input={handleInput} bind:value={id} {autofocus} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
columnId = $bindable(null),
|
||||
arrayValues = $bindable([]),
|
||||
operatorKey = $bindable(null),
|
||||
singleCondition = false
|
||||
singleCondition = false,
|
||||
schema = true
|
||||
}: {
|
||||
// We cast to any to not cause type errors in the input components
|
||||
/* eslint @typescript-eslint/no-explicit-any: 'off' */
|
||||
@@ -36,11 +37,40 @@
|
||||
arrayValues?: string[];
|
||||
operatorKey?: string | null;
|
||||
singleCondition?: boolean;
|
||||
schema?: boolean;
|
||||
} = $props();
|
||||
|
||||
let columnsArray = $derived($columns);
|
||||
let column = $derived(columnsArray.find((c) => c.id === columnId));
|
||||
let operatorsForColumn = $derived.by(() => {
|
||||
const systemFieldColumns: Record<string, Column> = {
|
||||
$id: { id: '$id', title: '$id', type: 'string' },
|
||||
$createdAt: { id: '$createdAt', title: '$createdAt', type: 'datetime' },
|
||||
$updatedAt: { id: '$updatedAt', title: '$updatedAt', type: 'datetime' }
|
||||
};
|
||||
|
||||
const columnsArray = $derived($columns);
|
||||
const isCustomAttribute = $derived(
|
||||
!schema &&
|
||||
columnId &&
|
||||
!systemFieldColumns[columnId] &&
|
||||
!columnsArray.find((c) => c.id === columnId)
|
||||
);
|
||||
const column = $derived.by(() => {
|
||||
if (!schema && columnId) {
|
||||
if (systemFieldColumns[columnId]) {
|
||||
return systemFieldColumns[columnId];
|
||||
}
|
||||
const existingColumn = columnsArray.find((c) => c.id === columnId);
|
||||
if (!existingColumn) {
|
||||
return { id: columnId, title: columnId, type: 'string' } as Column;
|
||||
}
|
||||
return existingColumn;
|
||||
}
|
||||
return columnsArray.find((c) => c.id === columnId);
|
||||
});
|
||||
|
||||
const operatorsForColumn = $derived.by(() => {
|
||||
if (!schema && (!column || isCustomAttribute)) {
|
||||
return Object.entries(operators).map(([k]) => ({ label: k, value: k }));
|
||||
}
|
||||
if (!column?.type) return [];
|
||||
return Object.entries(operators)
|
||||
.filter(([, v]) => v.types.includes(column.type))
|
||||
@@ -104,16 +134,46 @@
|
||||
|
||||
const dispatch = createEventDispatcher<{ clear: void; apply: { applied: number } }>();
|
||||
|
||||
function coerceValueByOperatorType(value: any, operatorTypes: string[]): any {
|
||||
if (typeof value !== 'string' || !value) return value;
|
||||
|
||||
if (operatorTypes.includes('integer') || operatorTypes.includes('double')) {
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue) && value.trim() !== '') {
|
||||
return numValue;
|
||||
}
|
||||
} else if (operatorTypes.includes('boolean')) {
|
||||
const lowerValue = value.toLowerCase().trim();
|
||||
if (lowerValue === 'true' || lowerValue === '1') {
|
||||
return true;
|
||||
} else if (lowerValue === 'false' || lowerValue === '0') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function addFilterAndReset() {
|
||||
const columnsWithVirtual =
|
||||
column && !columnsArray.find((c) => c.id === columnId)
|
||||
? [...columnsArray, column]
|
||||
: columnsArray;
|
||||
|
||||
// For distance operators, pass the distance as a separate parameter
|
||||
if (isDistanceOperator && distanceValue !== null && value !== null) {
|
||||
addFilter(columnsArray, columnId, operatorKey, value, arrayValues, distanceValue);
|
||||
addFilter(columnsWithVirtual, columnId, operatorKey, value, arrayValues, distanceValue);
|
||||
} else {
|
||||
const preparedValue =
|
||||
column?.type === 'datetime' && typeof value === 'string' && value
|
||||
? new Date(value).toISOString()
|
||||
: value;
|
||||
addFilter(columnsArray, columnId, operatorKey, preparedValue, arrayValues);
|
||||
let preparedValue = value;
|
||||
|
||||
if (column?.type === 'datetime' && typeof value === 'string' && value) {
|
||||
preparedValue = new Date(value).toISOString();
|
||||
} else if (!schema) {
|
||||
const operatorTypes = operator?.types || [];
|
||||
preparedValue = coerceValueByOperatorType(value, operatorTypes);
|
||||
}
|
||||
|
||||
addFilter(columnsWithVirtual, columnId, operatorKey, preparedValue, arrayValues);
|
||||
}
|
||||
|
||||
columnId = null;
|
||||
@@ -135,19 +195,23 @@
|
||||
addFilterAndReset();
|
||||
}}>
|
||||
<Layout.Stack gap="s" direction="row" alignItems="flex-start">
|
||||
<InputSelect
|
||||
id="column"
|
||||
options={columnOptions}
|
||||
placeholder="Select column"
|
||||
bind:value={columnId} />
|
||||
{#if schema}
|
||||
<InputSelect
|
||||
id="column"
|
||||
options={columnOptions}
|
||||
placeholder="Select column"
|
||||
bind:value={columnId} />
|
||||
{:else}
|
||||
<InputText id="column" placeholder="Enter attribute name" bind:value={columnId} />
|
||||
{/if}
|
||||
<InputSelect
|
||||
id="operator"
|
||||
disabled={!column}
|
||||
disabled={!column && schema}
|
||||
options={operatorsForColumn}
|
||||
placeholder="Select operator"
|
||||
bind:value={operatorKey} />
|
||||
</Layout.Stack>
|
||||
{#if column && operator && !operator?.hideInput}
|
||||
{#if (column || (!schema && columnId)) && operator && !operator?.hideInput}
|
||||
{#if column?.array}
|
||||
{#if column.format === 'enum'}
|
||||
<InputSelectCheckbox
|
||||
@@ -165,15 +229,15 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="u-margin-block-start-8">
|
||||
{#if column.format === 'enum'}
|
||||
{#if column?.format === 'enum'}
|
||||
<InputSelect
|
||||
id="value"
|
||||
bind:value
|
||||
placeholder="Select value"
|
||||
options={enumOptions} />
|
||||
{:else if column.type === 'integer' || column.type === 'double'}
|
||||
{:else if column?.type === 'integer' || column?.type === 'double'}
|
||||
<InputNumber id="value" bind:value placeholder="Enter value" />
|
||||
{:else if column.type === 'boolean'}
|
||||
{:else if column?.type === 'boolean'}
|
||||
<InputSelect
|
||||
id="value"
|
||||
placeholder="Select a value"
|
||||
@@ -183,11 +247,11 @@
|
||||
{ label: 'False', value: false }
|
||||
]}
|
||||
bind:value />
|
||||
{:else if column.type === 'datetime'}
|
||||
{:else if column?.type === 'datetime'}
|
||||
{#key value}
|
||||
<InputDateTime id="value" bind:value step={60} type="datetime-local" />
|
||||
{/key}
|
||||
{:else if column.type === 'point' || column.type === 'linestring' || column.type === 'polygon'}
|
||||
{:else if column?.type === 'point' || column?.type === 'linestring' || column?.type === 'polygon'}
|
||||
<InputPoint
|
||||
values={value || [0, 0]}
|
||||
onChangePoint={(index, newValue) => {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
export let enableApply = false;
|
||||
export let quickFilters = false;
|
||||
export let analyticsSource = '';
|
||||
export let schema = true;
|
||||
let displayQuickFilters = quickFilters;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -86,11 +87,26 @@
|
||||
value ||
|
||||
arrayValues.length)
|
||||
) {
|
||||
const columnsWithVirtual =
|
||||
!schema && selectedColumn && !$columns.find((c) => c.id === selectedColumn)
|
||||
? [
|
||||
...$columns,
|
||||
{ id: selectedColumn, title: selectedColumn, type: 'string' as const }
|
||||
]
|
||||
: $columns;
|
||||
|
||||
// For distance operators, pass the distance as a separate parameter
|
||||
if (isDistanceOperator && distanceValue !== null && value !== null) {
|
||||
addFilter($columns, selectedColumn, operatorKey, value, arrayValues, distanceValue);
|
||||
addFilter(
|
||||
columnsWithVirtual,
|
||||
selectedColumn,
|
||||
operatorKey,
|
||||
value,
|
||||
arrayValues,
|
||||
distanceValue
|
||||
);
|
||||
} else {
|
||||
addFilter($columns, selectedColumn, operatorKey, value, arrayValues);
|
||||
addFilter(columnsWithVirtual, selectedColumn, operatorKey, value, arrayValues);
|
||||
}
|
||||
selectedColumn = null;
|
||||
value = null;
|
||||
@@ -186,6 +202,7 @@
|
||||
bind:distanceValue
|
||||
bind:arrayValues
|
||||
{columns}
|
||||
{schema}
|
||||
{singleCondition}
|
||||
on:apply={afterApply}
|
||||
on:clear={() => (filtersAppliedCount = 0)} />
|
||||
@@ -243,6 +260,7 @@
|
||||
{:else}
|
||||
<Content
|
||||
{columns}
|
||||
{schema}
|
||||
bind:columnId={selectedColumn}
|
||||
bind:operatorKey
|
||||
bind:value
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script context="module" lang="ts">
|
||||
<script module lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { debounce } from '$lib/helpers/debounce';
|
||||
|
||||
// TODO - @itznotabug, use SvelteSet
|
||||
const batchQueue = new Set<() => void>();
|
||||
let batchPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -29,6 +30,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateId(id: string, head: number = 5, tail: number = 9): string {
|
||||
if (id.length <= head + tail) return id;
|
||||
return `${id.slice(0, head)}...${id.slice(-tail)}`;
|
||||
}
|
||||
|
||||
export function truncateText(node: HTMLElement) {
|
||||
let originalText = node.textContent;
|
||||
|
||||
@@ -84,18 +90,30 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Icon, Tag } from '@appwrite.io/pink-svelte';
|
||||
import { Copy } from '.';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Icon, Tag } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
|
||||
import type { TooltipPlacement } from '$lib/components/copy.svelte';
|
||||
|
||||
export let value: string;
|
||||
export let event: string = null;
|
||||
|
||||
export let tooltipPortal = false;
|
||||
export let tooltipDelay: number = 0;
|
||||
export let tooltipPlacement: TooltipPlacement = undefined;
|
||||
export let copyText: string | undefined = undefined;
|
||||
const {
|
||||
value,
|
||||
event = null,
|
||||
tooltipPortal = false,
|
||||
tooltipDelay = 0,
|
||||
tooltipPlacement,
|
||||
copyText,
|
||||
children
|
||||
}: {
|
||||
value: string;
|
||||
event?: string | null;
|
||||
tooltipPortal?: boolean;
|
||||
tooltipDelay?: number;
|
||||
tooltipPlacement?: TooltipPlacement;
|
||||
truncate?: boolean;
|
||||
copyText?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#key value}
|
||||
@@ -107,7 +125,7 @@
|
||||
style:overflow="hidden"
|
||||
style:word-break="break-all"
|
||||
use:truncateText>
|
||||
<slot />
|
||||
{@render children()}
|
||||
</span>
|
||||
</Tag>
|
||||
</Copy>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { tick } from 'svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import {
|
||||
readImpersonationTargetUserId,
|
||||
readTargetSnapshot,
|
||||
readOperatorSnapshot,
|
||||
stopImpersonation,
|
||||
impersonationRevision
|
||||
} from '$lib/appwrite/impersonation';
|
||||
import { user } from '$lib/stores/user';
|
||||
|
||||
// Re-read sessionStorage whenever the revision bumps (start/stop) or $user changes
|
||||
// (account invalidated after impersonation). Without this the plain function calls
|
||||
// only execute once on mount and show stale data from previous sessions.
|
||||
let targetSnapshot = readTargetSnapshot();
|
||||
let operatorSnapshot = readOperatorSnapshot();
|
||||
let targetId = readImpersonationTargetUserId();
|
||||
$: {
|
||||
void $impersonationRevision;
|
||||
void $user;
|
||||
targetSnapshot = readTargetSnapshot();
|
||||
operatorSnapshot = readOperatorSnapshot();
|
||||
targetId = readImpersonationTargetUserId();
|
||||
}
|
||||
|
||||
$: isImpersonating = !!$user?.impersonatorUserId || !!targetId;
|
||||
|
||||
// Effective user: always from the stored target snapshot (written at pick-time from
|
||||
// the search result, so it's always the correct selected user identity).
|
||||
$: effectiveLabel = targetSnapshot
|
||||
? targetSnapshot.name || targetSnapshot.email || targetSnapshot.$id
|
||||
: $user?.impersonatorUserId
|
||||
? $user?.name || $user?.email || $user?.$id
|
||||
: (targetId ?? 'unknown');
|
||||
|
||||
// Operator: always from the stored operator snapshot (written at pick-time from $user,
|
||||
// which is the real operator session before the header is applied).
|
||||
$: operatorLabel = operatorSnapshot
|
||||
? operatorSnapshot.name || operatorSnapshot.email || operatorSnapshot.$id
|
||||
: ($user?.impersonatorUserId ?? 'operator');
|
||||
|
||||
async function exit() {
|
||||
stopImpersonation();
|
||||
await tick();
|
||||
await goto(base, { invalidateAll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isImpersonating}
|
||||
<HeaderAlert type="warning" title="Impersonation active">
|
||||
<svelte:fragment>
|
||||
You are operating as <b>{effectiveLabel}</b> on behalf of operator
|
||||
<b>{operatorLabel}</b>.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button secondary on:click={exit}>Exit impersonation</Button>
|
||||
</svelte:fragment>
|
||||
</HeaderAlert>
|
||||
{/if}
|
||||
@@ -0,0 +1,368 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base, resolve } from '$app/paths';
|
||||
import { Query, type Models } from '@appwrite.io/console';
|
||||
import { Layout, Modal, Typography, Icon, Spinner, Tag } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate, IconSearch } from '@appwrite.io/pink-icons-svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { AvatarInitials, Copy } from '$lib/components';
|
||||
import {
|
||||
readOperatorSnapshot,
|
||||
startImpersonation,
|
||||
readImpersonationTargetUserId,
|
||||
impersonationRevision
|
||||
} from '$lib/appwrite/impersonation';
|
||||
import type { OperatorSnapshot, TargetSnapshot } from '$lib/appwrite/impersonation';
|
||||
|
||||
export let show = false;
|
||||
|
||||
const RECENTS_KEY_PREFIX = 'console.impersonation.recents.';
|
||||
const MAX_RECENTS = 5;
|
||||
|
||||
type ImpersonationTarget = {
|
||||
$id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
prefs?: {
|
||||
organization?: string;
|
||||
};
|
||||
};
|
||||
type RecentTarget = ImpersonationTarget;
|
||||
|
||||
function recentsKey(): string | null {
|
||||
const op = readOperatorSnapshot();
|
||||
const id = op?.$id ?? $user?.$id;
|
||||
return id ? `${RECENTS_KEY_PREFIX}${id}` : null;
|
||||
}
|
||||
|
||||
function readRecents(): RecentTarget[] {
|
||||
const key = recentsKey();
|
||||
if (!key) return [];
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) ?? '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecent(target: ImpersonationTarget) {
|
||||
const key = recentsKey();
|
||||
if (!key) return;
|
||||
const existing = readRecents().filter((r) => r.$id !== target.$id);
|
||||
const updated: RecentTarget[] = [
|
||||
{
|
||||
$id: target.$id,
|
||||
name: target.name,
|
||||
email: target.email,
|
||||
prefs: {
|
||||
organization: target.prefs?.organization
|
||||
}
|
||||
},
|
||||
...existing
|
||||
].slice(0, MAX_RECENTS);
|
||||
localStorage.setItem(key, JSON.stringify(updated));
|
||||
}
|
||||
|
||||
let search = '';
|
||||
let results: Models.User<Record<string, string>>[] = [];
|
||||
let loading = false;
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let recents: RecentTarget[] = [];
|
||||
let operatorId = readOperatorSnapshot()?.$id ?? null;
|
||||
let impersonatingId = readImpersonationTargetUserId();
|
||||
|
||||
$: if (show) {
|
||||
recents = readRecents();
|
||||
}
|
||||
|
||||
function debounce(fn: () => void, delay = 300) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(fn, delay);
|
||||
}
|
||||
|
||||
async function fetchUsers(prefix: string) {
|
||||
if (!prefix.trim()) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const response = await sdk.forConsoleAsOperator.users.list({
|
||||
queries: [
|
||||
Query.or([
|
||||
Query.startsWith('name', prefix),
|
||||
Query.startsWith('email', prefix),
|
||||
Query.startsWith('phone', prefix),
|
||||
Query.startsWith('$id', prefix)
|
||||
]),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(25),
|
||||
Query.offset(0)
|
||||
]
|
||||
});
|
||||
results = response.users;
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: debounce(() => fetchUsers(search));
|
||||
|
||||
$: activeAccountId = $user?.$id;
|
||||
$: {
|
||||
void $impersonationRevision;
|
||||
void $user;
|
||||
operatorId = readOperatorSnapshot()?.$id ?? null;
|
||||
impersonatingId = readImpersonationTargetUserId();
|
||||
}
|
||||
|
||||
function isDisabled(id: string): boolean {
|
||||
return id === activeAccountId || (!!operatorId && id === operatorId);
|
||||
}
|
||||
|
||||
function disabledLabel(id: string): string {
|
||||
if (id === activeAccountId) return 'Current';
|
||||
if (operatorId && id === operatorId) return 'Operator';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getTargetPath(target: ImpersonationTarget): string {
|
||||
if (!target.prefs?.organization) return base;
|
||||
|
||||
return resolve('/(console)/organization-[organization]', {
|
||||
organization: target.prefs.organization
|
||||
});
|
||||
}
|
||||
|
||||
async function selectUser(target: ImpersonationTarget) {
|
||||
if (isDisabled(target.$id)) return;
|
||||
|
||||
const existingOperator = readOperatorSnapshot();
|
||||
const operator: OperatorSnapshot = existingOperator ?? {
|
||||
$id: $user.$id,
|
||||
name: $user.name,
|
||||
email: $user.email
|
||||
};
|
||||
|
||||
const targetSnapshot: TargetSnapshot = {
|
||||
$id: target.$id,
|
||||
name: target.name,
|
||||
email: target.email
|
||||
};
|
||||
|
||||
saveRecent(target);
|
||||
startImpersonation(targetSnapshot, operator);
|
||||
search = '';
|
||||
results = [];
|
||||
show = false;
|
||||
|
||||
await goto(getTargetPath(target), { invalidateAll: true });
|
||||
}
|
||||
|
||||
function displayName(u: { name: string; email: string; $id: string }): string {
|
||||
return u.name || u.email || u.$id;
|
||||
}
|
||||
|
||||
$: listToShow = search.trim() ? results : [];
|
||||
$: showRecents = !search.trim() && recents.length > 0;
|
||||
$: showNoResults = !!search.trim() && !loading && results.length === 0;
|
||||
</script>
|
||||
|
||||
<Modal size="s" title="Impersonate user" bind:open={show} hideFooter>
|
||||
<svelte:fragment slot="description">
|
||||
Matches the start of name, email, phone, or user ID. The Console runs with the selected
|
||||
account's access until you end impersonation.
|
||||
</svelte:fragment>
|
||||
|
||||
<Layout.Stack gap="m">
|
||||
<!-- Search input -->
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon-wrap">
|
||||
<Icon icon={IconSearch} size="s" />
|
||||
</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="search-field"
|
||||
type="search"
|
||||
placeholder="Name, email, phone, or user ID..."
|
||||
bind:value={search} />
|
||||
{#if loading}
|
||||
<span class="search-end">
|
||||
<Spinner size="s" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
{#if showNoResults}
|
||||
<Typography.Text>No users found</Typography.Text>
|
||||
{/if}
|
||||
|
||||
<!-- Search results or recents -->
|
||||
{#if showRecents}
|
||||
<Typography.Text variant="m-500">Recent</Typography.Text>
|
||||
{/if}
|
||||
|
||||
{#each listToShow.length ? listToShow : showRecents ? recents : [] as item (item.$id)}
|
||||
{@const disabled = isDisabled(item.$id)}
|
||||
{@const label = disabledLabel(item.$id)}
|
||||
{@const active = item.$id === impersonatingId}
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="button"
|
||||
class="user-item"
|
||||
class:is-disabled={disabled}
|
||||
on:click={() => selectUser(item)}>
|
||||
<AvatarInitials name={displayName(item)} size="m" />
|
||||
<div class="user-details">
|
||||
<Typography.Text variant="m-500">{displayName(item)}</Typography.Text>
|
||||
{#if item.email && item.email !== displayName(item)}
|
||||
<Typography.Text>{item.email}</Typography.Text>
|
||||
{/if}
|
||||
<!-- ID copy row — clicks stop propagation so they don't trigger selectUser -->
|
||||
<div class="id-row" role="presentation" on:click|stopPropagation>
|
||||
<Copy value={item.$id} event="user_impersonate_id">
|
||||
<Tag size="xs" variant="code">
|
||||
<Icon size="s" icon={IconDuplicate} slot="start" />
|
||||
{item.$id}
|
||||
</Tag>
|
||||
</Copy>
|
||||
</div>
|
||||
</div>
|
||||
{#if label}
|
||||
<span class="badge">{label}</span>
|
||||
{:else if active}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
/* Search bar */
|
||||
.search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-neutral-20));
|
||||
border-radius: var(--border-radius-s, 6px);
|
||||
padding-inline: 0.75rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.theme-dark) .search-wrap {
|
||||
border-color: hsl(var(--color-neutral-70));
|
||||
}
|
||||
|
||||
.search-icon-wrap {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--color-neutral-50));
|
||||
}
|
||||
|
||||
.search-field {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding-block: 0.625rem;
|
||||
font-size: var(--font-size-1, 0.875rem);
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-field::placeholder {
|
||||
color: hsl(var(--color-neutral-50));
|
||||
}
|
||||
|
||||
/* Hide native clear button — we rely on the user clearing manually */
|
||||
.search-field::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.search-end {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* User item */
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius-m, 8px);
|
||||
border: 1px solid hsl(var(--color-neutral-10));
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
:global(.theme-dark) .user-item {
|
||||
border-color: hsl(var(--color-neutral-80));
|
||||
}
|
||||
|
||||
.user-item:not(.is-disabled):hover {
|
||||
background: hsl(var(--color-neutral-5));
|
||||
}
|
||||
|
||||
:global(.theme-dark) .user-item:not(.is-disabled):hover {
|
||||
background: hsl(var(--color-neutral-85));
|
||||
}
|
||||
|
||||
.user-item.is-disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Re-enable pointer events on the ID copy row even when item is disabled
|
||||
so the user can still copy the ID if needed */
|
||||
.user-item.is-disabled .id-row {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.id-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
font-size: var(--font-size-0, 0.75rem);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-neutral-10));
|
||||
color: hsl(var(--color-neutral-60));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.theme-dark) .badge {
|
||||
background: hsl(var(--color-neutral-80));
|
||||
color: hsl(var(--color-neutral-40));
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: hsl(var(--color-success-10));
|
||||
color: hsl(var(--color-success-60));
|
||||
}
|
||||
|
||||
:global(.theme-dark) .badge-active {
|
||||
background: hsl(var(--color-success-80));
|
||||
color: hsl(var(--color-success-30));
|
||||
}
|
||||
</style>
|
||||
@@ -30,27 +30,29 @@
|
||||
</script>
|
||||
|
||||
<Form isModal {onSubmit}>
|
||||
<Modal {backdrop} {size} {title} bind:open={show} {hideFooter} {dismissible}>
|
||||
<slot slot="description" name="description" />
|
||||
{#if error}
|
||||
<div bind:this={alert}>
|
||||
<Alert.Inline
|
||||
dismissible
|
||||
status="warning"
|
||||
on:dismiss={() => {
|
||||
error = null;
|
||||
}}>
|
||||
{error}
|
||||
</Alert.Inline>
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<slot name="footer" />
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
<div class:modal-no-backdrop={!backdrop}>
|
||||
<Modal {size} {title} bind:open={show} {hideFooter} {dismissible}>
|
||||
<slot slot="description" name="description" />
|
||||
{#if error}
|
||||
<div bind:this={alert}>
|
||||
<Alert.Inline
|
||||
dismissible
|
||||
status="warning"
|
||||
on:dismiss={() => {
|
||||
error = null;
|
||||
}}>
|
||||
{error}
|
||||
</Alert.Inline>
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<slot name="footer" />
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<style>
|
||||
@@ -58,4 +60,10 @@
|
||||
:global(dialog section) {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
:global(.modal-no-backdrop dialog::backdrop) {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
import { page } from '$app/state';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { isProjectBlocked as getIsProjectBlocked } from '$lib/helpers/project';
|
||||
import ImpersonationPicker from '$lib/components/impersonation/picker.svelte';
|
||||
import {
|
||||
readImpersonationTargetUserId,
|
||||
impersonationRevision
|
||||
} from '$lib/appwrite/impersonation';
|
||||
import { IconEye } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
let showSupport = false;
|
||||
|
||||
@@ -111,6 +117,19 @@
|
||||
$: currentOrg = organizations.find((org) => org.isSelected);
|
||||
$: isProjectBlocked = getIsProjectBlocked(currentProject);
|
||||
|
||||
let canImpersonate = false;
|
||||
|
||||
// Show impersonation picker when the operator has the capability or impersonation is active.
|
||||
$: {
|
||||
void $impersonationRevision;
|
||||
canImpersonate =
|
||||
$user?.impersonator === true ||
|
||||
!!$user?.impersonatorUserId ||
|
||||
!!readImpersonationTargetUserId();
|
||||
}
|
||||
|
||||
let showImpersonationPicker = false;
|
||||
|
||||
beforeNavigate(() => (showAccountMenu = false));
|
||||
</script>
|
||||
|
||||
@@ -198,6 +217,22 @@
|
||||
</svelte:fragment>
|
||||
</DropList>
|
||||
</Layout.Stack>
|
||||
{#if canImpersonate}
|
||||
<ImpersonationPicker bind:show={showImpersonationPicker} />
|
||||
<Tooltip disabled={showImpersonationPicker}>
|
||||
<Button.Button
|
||||
variant="text"
|
||||
aria-label="Impersonate user"
|
||||
on:click={(event) => {
|
||||
showImpersonationPicker = true;
|
||||
(event.currentTarget as HTMLElement).blur();
|
||||
}}
|
||||
icon>
|
||||
<Icon icon={IconEye} />
|
||||
</Button.Button>
|
||||
<span slot="tooltip">Impersonate user</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<Layout.Stack direction="row">
|
||||
<Tooltip>
|
||||
<Button.Button
|
||||
|
||||
@@ -90,10 +90,14 @@
|
||||
}
|
||||
|
||||
if (selectedProjectsToDelete?.length) {
|
||||
const projectsDeletionPromises = selectedProjectsToDelete.map((projectId) => ({
|
||||
projectId,
|
||||
promise: sdk.forConsole.projects.delete({ projectId })
|
||||
}));
|
||||
const projectsDeletionPromises = selectedProjectsToDelete.map((projectId) => {
|
||||
const projectToDelete = projects.find((project) => project.$id === projectId);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
promise: sdk.forProject(projectToDelete.region, projectId).project.delete()
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<script lang="ts">
|
||||
import { Query } from '@appwrite.io/console';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { SortState } from '$database/store';
|
||||
import { Icon } from '@appwrite.io/pink-svelte';
|
||||
import { IconChevronDown, IconChevronUp, IconSelector } from '@appwrite.io/pink-icons-svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { SortState } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
|
||||
|
||||
let {
|
||||
column,
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
allowNoColumns?: boolean;
|
||||
showAnyway?: boolean;
|
||||
disableButton?: boolean;
|
||||
onCustomOptionClick?: () => void;
|
||||
onPreferencesUpdated?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -34,7 +36,9 @@
|
||||
hideColumns = false,
|
||||
allowNoColumns = false,
|
||||
showAnyway = false,
|
||||
disableButton = false
|
||||
disableButton = false,
|
||||
onCustomOptionClick = null,
|
||||
onPreferencesUpdated = null
|
||||
}: Props = $props();
|
||||
|
||||
let showCountBadge = $state(false);
|
||||
@@ -53,6 +57,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreferencesUpdated() {
|
||||
updateBadgeState();
|
||||
onPreferencesUpdated?.();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
showCountBadge = !onlyIcon || !!preferences.getKey(preferenceKey, false);
|
||||
});
|
||||
@@ -65,7 +74,8 @@
|
||||
{showAnyway}
|
||||
{isCustomTable}
|
||||
{allowNoColumns}
|
||||
onPreferencesUpdated={updateBadgeState}>
|
||||
{onCustomOptionClick}
|
||||
onPreferencesUpdated={handlePreferencesUpdated}>
|
||||
{#snippet children(toggle, selectedColumnsNumber)}
|
||||
<Button.Button
|
||||
size="s"
|
||||
|
||||
+33
-4
@@ -50,6 +50,9 @@ export enum Dependencies {
|
||||
TABLE = 'dependency:table',
|
||||
ROW = 'dependency:row',
|
||||
ROWS = 'dependency:rows',
|
||||
COLLECTION = 'dependency:collection',
|
||||
DOCUMENT = 'dependency:document',
|
||||
DOCUMENTS = 'dependency:documents',
|
||||
BUCKET = 'dependency:bucket',
|
||||
FILE = 'dependency:file',
|
||||
FILE_TOKENS = 'dependency:file_tokens',
|
||||
@@ -109,8 +112,8 @@ export const defaultScopes: string[] = [
|
||||
'projects.write',
|
||||
'locale.read',
|
||||
'avatars.read',
|
||||
'execution.read',
|
||||
'execution.write',
|
||||
'executions.read',
|
||||
'executions.write',
|
||||
'targets.read',
|
||||
'targets.write',
|
||||
'subscribers.write',
|
||||
@@ -336,17 +339,31 @@ export const scopes: ScopeDefinition[] = [
|
||||
icon: 'lightning-bolt'
|
||||
},
|
||||
{
|
||||
scope: 'execution.read',
|
||||
scope: 'executions.read',
|
||||
description: "Access to read your project's execution logs",
|
||||
category: 'Functions',
|
||||
icon: 'lightning-bolt'
|
||||
},
|
||||
{
|
||||
scope: 'execution.write',
|
||||
scope: 'executions.write',
|
||||
description: "Access to execute your project's functions",
|
||||
category: 'Functions',
|
||||
icon: 'lightning-bolt'
|
||||
},
|
||||
{
|
||||
scope: 'execution.read',
|
||||
description: "Access to read your project's execution logs",
|
||||
category: 'Functions',
|
||||
icon: 'lightning-bolt',
|
||||
deprecated: true
|
||||
},
|
||||
{
|
||||
scope: 'execution.write',
|
||||
description: "Access to execute your project's functions",
|
||||
category: 'Functions',
|
||||
icon: 'lightning-bolt',
|
||||
deprecated: true
|
||||
},
|
||||
{
|
||||
scope: 'targets.read',
|
||||
description: "Access to read your project's messaging targets",
|
||||
@@ -497,6 +514,18 @@ export const scopes: ScopeDefinition[] = [
|
||||
description: "Access to create, update, and delete your project's API keys",
|
||||
category: 'Other',
|
||||
icon: 'globe'
|
||||
},
|
||||
{
|
||||
scope: 'project.read',
|
||||
description: "Access to read your project's information",
|
||||
category: 'Other',
|
||||
icon: 'globe'
|
||||
},
|
||||
{
|
||||
scope: 'project.write',
|
||||
description: "Access to update your project's information",
|
||||
category: 'Other',
|
||||
icon: 'globe'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
snapThreshold = 3,
|
||||
disabled = false,
|
||||
id = ID.unique(),
|
||||
extraBlockStart = false,
|
||||
onchange
|
||||
}: {
|
||||
min?: number;
|
||||
@@ -29,6 +30,7 @@
|
||||
snapThreshold?: number;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
extraBlockStart?: boolean;
|
||||
onchange?: (value: number) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -164,7 +166,12 @@
|
||||
</script>
|
||||
|
||||
<div class="seekbar">
|
||||
<div class="track" role="presentation" bind:this={trackRef} onclick={handleTrackClick}>
|
||||
<div
|
||||
class="track"
|
||||
class:extra-space={extraBlockStart}
|
||||
role="presentation"
|
||||
bind:this={trackRef}
|
||||
onclick={handleTrackClick}>
|
||||
{#if maxAllowed < max}
|
||||
<div
|
||||
class="disabled-area"
|
||||
@@ -238,12 +245,13 @@
|
||||
}
|
||||
|
||||
.breakpoints {
|
||||
position: absolute;
|
||||
top: calc(1.25rem + var(--seekbar-height) + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
position: absolute;
|
||||
margin-inline: 1px;
|
||||
pointer-events: none;
|
||||
top: calc(1.25rem + var(--seekbar-height) + 8px);
|
||||
}
|
||||
|
||||
.breakpoint {
|
||||
@@ -267,6 +275,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.extra-space {
|
||||
margin-block-start: 2px;
|
||||
}
|
||||
|
||||
.disabled-area {
|
||||
top: 0;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export let value = '';
|
||||
export let autofocus = true;
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
|
||||
let error = false;
|
||||
@@ -25,6 +26,7 @@
|
||||
{autofocus}
|
||||
{required}
|
||||
{leadingIcon}
|
||||
{disabled}
|
||||
id="id"
|
||||
placeholder="Enter ID"
|
||||
maxlength={36}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export let value = '';
|
||||
export let autofocus = true;
|
||||
export let disabled = false;
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
|
||||
let error = false;
|
||||
@@ -24,6 +25,7 @@
|
||||
{pattern}
|
||||
{autofocus}
|
||||
{leadingIcon}
|
||||
{disabled}
|
||||
id="id"
|
||||
placeholder="Enter ID"
|
||||
maxlength={36}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export let helper: string | undefined = undefined;
|
||||
export let pattern: string | undefined = undefined;
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
export let max: number | undefined = undefined;
|
||||
|
||||
let value = '';
|
||||
let error: string;
|
||||
@@ -30,13 +31,29 @@
|
||||
$: if (value) {
|
||||
error = null;
|
||||
}
|
||||
|
||||
$: {
|
||||
// filter out empty or whitespace-only tags
|
||||
const cleaned = tags.filter((tag) => tag.trim().length > 0);
|
||||
if (cleaned.length !== tags.length) {
|
||||
tags = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
$: if (max !== undefined && tags.length > max) {
|
||||
error = `Maximum ${max} fields allowed`;
|
||||
} else if (max === undefined || tags.length <= max) {
|
||||
error = null;
|
||||
}
|
||||
|
||||
$: isDisabled = disabled || (max !== undefined && tags.length >= max);
|
||||
</script>
|
||||
|
||||
<Input.Tags
|
||||
{label}
|
||||
{id}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
disabled={isDisabled}
|
||||
{pattern}
|
||||
{required}
|
||||
{leadingIcon}
|
||||
@@ -44,3 +61,11 @@
|
||||
helper={error || helper}
|
||||
on:invalid={handleInvalid}
|
||||
state={error ? 'error' : 'default'}><slot name="info" slot="info" /></Input.Tags>
|
||||
|
||||
<style>
|
||||
/* hotfix due to root styles increasing block-size */
|
||||
:global(.tag) {
|
||||
border: none;
|
||||
block-size: unset;
|
||||
}
|
||||
</style>
|
||||
|
||||
+3
-2
@@ -5,7 +5,6 @@ import type { Models } from '@appwrite.io/console';
|
||||
// Parse feature flags from env as a string array (exact match only)
|
||||
const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
|
||||
|
||||
// @ts-expect-error: unused method!
|
||||
function isFlagEnabled(name: string) {
|
||||
// loose generic to allow safe access while retaining type safety
|
||||
return <T extends { account?: Account; organization?: Models.Organization }>(data: T) => {
|
||||
@@ -19,4 +18,6 @@ function isFlagEnabled(name: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export const flags = {};
|
||||
export const flags = {
|
||||
multiDb: isFlagEnabled('multi-db')
|
||||
};
|
||||
|
||||
+36
-27
@@ -84,18 +84,41 @@ export async function generateFields(
|
||||
}
|
||||
}
|
||||
|
||||
function generateDefaultRecord(
|
||||
id: string
|
||||
): Record<
|
||||
string,
|
||||
string | number | boolean | Array<string | number | boolean | NestedNumberArray>
|
||||
> {
|
||||
return {
|
||||
$id: id,
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
age: faker.number.int({ min: 18, max: 80 }),
|
||||
city: faker.location.city(),
|
||||
description: faker.lorem.sentence(),
|
||||
active: faker.datatype.boolean(),
|
||||
location: [faker.location.longitude(), faker.location.latitude()],
|
||||
route: Array.from({ length: 5 }, () => [
|
||||
faker.location.longitude(),
|
||||
faker.location.latitude()
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
export function generateFakeRecords(
|
||||
fields: Field[],
|
||||
count: number
|
||||
count: number,
|
||||
field?: Field[]
|
||||
): {
|
||||
ids: string[];
|
||||
rows: Models.Row[];
|
||||
} {
|
||||
if (count <= 0) return { ids: [], rows: [] };
|
||||
|
||||
const filteredColumns = fields.filter(
|
||||
(col) => col.type !== 'relationship' && col.status === 'available'
|
||||
);
|
||||
const filteredColumns =
|
||||
field?.filter(
|
||||
(column) => column.type !== 'relationship' && column.status === 'available'
|
||||
) ?? [];
|
||||
|
||||
const ids: string[] = [];
|
||||
const rows: Models.Row[] = [];
|
||||
@@ -104,35 +127,21 @@ export function generateFakeRecords(
|
||||
const id = ID.unique();
|
||||
ids.push(id);
|
||||
|
||||
let row: Record<
|
||||
let record: Record<
|
||||
string,
|
||||
string | number | boolean | Array<string | number | boolean | NestedNumberArray>
|
||||
> = {
|
||||
$id: id
|
||||
};
|
||||
>;
|
||||
|
||||
if (filteredColumns.length === 0) {
|
||||
row = {
|
||||
$id: id,
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
age: faker.number.int({ min: 18, max: 80 }),
|
||||
city: faker.location.city(),
|
||||
description: faker.lorem.sentence(),
|
||||
active: faker.datatype.boolean(),
|
||||
location: [faker.location.longitude(), faker.location.latitude()],
|
||||
route: Array.from({ length: 5 }, () => [
|
||||
faker.location.longitude(),
|
||||
faker.location.latitude()
|
||||
])
|
||||
};
|
||||
record = generateDefaultRecord(id);
|
||||
} else {
|
||||
record = { $id: id };
|
||||
for (const column of filteredColumns) {
|
||||
row[column.key] = generateValueForField(column);
|
||||
record[column.key] = generateValueForField(column);
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(row as unknown as Models.Row);
|
||||
rows.push(record as Models.Row);
|
||||
}
|
||||
|
||||
return { ids, rows };
|
||||
@@ -168,8 +177,8 @@ function generateValueForField(
|
||||
const items: Array<string | number | NestedNumberArray | boolean> = [];
|
||||
|
||||
for (let i = 0; i < arraySize; i++) {
|
||||
const itemAttribute = { ...field, array: false };
|
||||
const item = generateSingleValue(itemAttribute);
|
||||
const itemField = { ...field, array: false };
|
||||
const item = generateSingleValue(itemField);
|
||||
if (item !== null) {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@ import { Dependencies } from '$lib/constants';
|
||||
import { Submit, trackEvent } from '$lib/actions/analytics';
|
||||
import { base } from '$app/paths';
|
||||
import { uploader } from '$lib/stores/uploader';
|
||||
import { stopImpersonation, readImpersonationTargetUserId } from '$lib/appwrite/impersonation';
|
||||
|
||||
export async function logout(redirect: boolean = true) {
|
||||
// Clear impersonation before deleting the real session so the server
|
||||
// sees the operator's session, not an impersonated one.
|
||||
if (readImpersonationTargetUserId()) {
|
||||
stopImpersonation();
|
||||
}
|
||||
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
uploader.reset();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
/**
|
||||
* Strict typed `Object.entries`
|
||||
* Extracted from https://github.com/antfu/utils
|
||||
@@ -58,3 +60,19 @@ export function parseIfString(value: unknown): unknown {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* `stringify` is faster on small to medium-sized objects
|
||||
* while the `recursive` logic via `deepEqual` is faster on big objects!
|
||||
*/
|
||||
export function areObjectsSame<T>(
|
||||
objectOne: T,
|
||||
objectTwo: T,
|
||||
method: 'recursive' | 'stringify' = 'stringify'
|
||||
): boolean {
|
||||
if (method === 'recursive') {
|
||||
return deepEqual(objectOne, objectTwo);
|
||||
}
|
||||
|
||||
return JSON.stringify(objectOne) === JSON.stringify(objectTwo);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
|
||||
type FuzzySearchOptions = {
|
||||
limit?: number;
|
||||
minOccurrences?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds common attribute keys across documents by analyzing their frequency.
|
||||
*/
|
||||
export function fuzzySearchKeys(
|
||||
documents: Models.Document[],
|
||||
options: FuzzySearchOptions = {}
|
||||
): string[] | null {
|
||||
if (!documents || documents.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { minOccurrences = 2, limit } = options;
|
||||
|
||||
const attributeCount = new Map<string, number>();
|
||||
const threshold = minOccurrences === null ? 5 : Math.max(2, Math.min(minOccurrences, 5));
|
||||
|
||||
// Process only first 5 documents
|
||||
const docLimit = Math.min(5, documents.length);
|
||||
|
||||
for (let docIndex = 0; docIndex < docLimit; docIndex++) {
|
||||
const document = documents[docIndex];
|
||||
if (!document || typeof document !== 'object') continue;
|
||||
|
||||
// track per-document keys
|
||||
const seenInDoc = new Map<string, boolean>();
|
||||
|
||||
for (const key in document) {
|
||||
if (key[0] === '$' || seenInDoc.has(key)) continue;
|
||||
|
||||
seenInDoc.set(key, true);
|
||||
attributeCount.set(key, (attributeCount.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
for (const [key, count] of attributeCount) {
|
||||
if (count >= threshold) {
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
result.sort();
|
||||
|
||||
return limit && limit > 0 ? result.slice(0, limit) : result;
|
||||
}
|
||||
@@ -26,8 +26,6 @@ export function isHTMLInputElement(el: unknown): el is HTMLInputElement {
|
||||
return el instanceof HTMLInputElement;
|
||||
}
|
||||
|
||||
export type Prettify<T> = T & {};
|
||||
|
||||
const columnTypes = [
|
||||
'string',
|
||||
'varchar',
|
||||
@@ -42,7 +40,8 @@ const columnTypes = [
|
||||
'enum',
|
||||
'point',
|
||||
'linestring',
|
||||
'polygon'
|
||||
'polygon',
|
||||
'dynamic' /* a primitive or an object */
|
||||
] as const;
|
||||
export type ColumnType = (typeof columnTypes)[number];
|
||||
export type Column = PinkColumn & {
|
||||
@@ -52,6 +51,7 @@ export type Column = PinkColumn & {
|
||||
array?: boolean;
|
||||
format?: string;
|
||||
exclude?: boolean;
|
||||
disable?: boolean;
|
||||
elements?: string[] | { value: string | number; label: string }[];
|
||||
encrypt?: boolean;
|
||||
icon?: ComponentType;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import type { BeforeNavigate } from '@sveltejs/kit';
|
||||
|
||||
type UnsavedChangesGuardOptions = {
|
||||
message?: string;
|
||||
hasUnsavedChanges: () => boolean;
|
||||
onConfirmNavigate?: () => void;
|
||||
shouldBlockNavigation?: (navigation: BeforeNavigate) => boolean;
|
||||
onShowConfirmModal?: (url: string, onConfirm: () => void | Promise<void>) => void;
|
||||
};
|
||||
|
||||
export const setupUnsavedChangesGuard = ({
|
||||
message,
|
||||
hasUnsavedChanges,
|
||||
onConfirmNavigate,
|
||||
shouldBlockNavigation,
|
||||
onShowConfirmModal
|
||||
}: UnsavedChangesGuardOptions) => {
|
||||
message = message ?? 'You have unsaved changes. Are you sure you want to leave?';
|
||||
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!hasUnsavedChanges()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = message;
|
||||
return message;
|
||||
};
|
||||
|
||||
beforeNavigate((navigation: BeforeNavigate) => {
|
||||
if (!hasUnsavedChanges()) return;
|
||||
if (shouldBlockNavigation && !shouldBlockNavigation(navigation)) return;
|
||||
|
||||
// If custom modal handler is provided, use it
|
||||
if (onShowConfirmModal && navigation.to?.url) {
|
||||
navigation.cancel();
|
||||
const targetUrl = navigation.to.url.href;
|
||||
const handleConfirm = async () => {
|
||||
onConfirmNavigate?.();
|
||||
|
||||
// eslint-disable-next-line
|
||||
await goto(targetUrl);
|
||||
};
|
||||
|
||||
onShowConfirmModal(targetUrl, handleConfirm);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to native confirm dialog
|
||||
if (!confirm(message)) {
|
||||
navigation.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirmNavigate?.();
|
||||
});
|
||||
|
||||
return { beforeUnload };
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 515 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { setContext, onMount, onDestroy } from 'svelte';
|
||||
import { isTabletViewport } from '$lib/stores/viewport';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { bannerSpacing } from './headerAlert.svelte';
|
||||
|
||||
// Signal to child HeaderAlert components that layout is managed here
|
||||
setContext('isInAlertStack', true);
|
||||
|
||||
let container: HTMLElement | null = null;
|
||||
let resizeObserver: ResizeObserver;
|
||||
|
||||
function setNavigationHeight() {
|
||||
const alertHeight = container ? container.getBoundingClientRect().height : 0;
|
||||
|
||||
if (alertHeight) {
|
||||
bannerSpacing.set(`${alertHeight}px`);
|
||||
} else {
|
||||
bannerSpacing.set(null);
|
||||
}
|
||||
|
||||
const header: HTMLHeadingElement = document.querySelector('main > header');
|
||||
const sidebar: HTMLElement = document.querySelector('main nav');
|
||||
const contentSection: HTMLElement = document.querySelector('main .main-content');
|
||||
|
||||
if (header) {
|
||||
header.style.top = `${alertHeight}px`;
|
||||
}
|
||||
|
||||
if (sidebar) {
|
||||
const headerHeight = header?.getBoundingClientRect().height ?? 0;
|
||||
const topOffset = alertHeight + ($isTabletViewport ? 0 : headerHeight);
|
||||
sidebar.style.top = `${topOffset}px`;
|
||||
sidebar.style.height = `calc(100vh - ${topOffset}px)`;
|
||||
}
|
||||
|
||||
if (contentSection) {
|
||||
contentSection.style.paddingBlockStart = `${alertHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (container) {
|
||||
resizeObserver = new ResizeObserver(setNavigationHeight);
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
container = null;
|
||||
setNavigationHeight();
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
afterNavigate(() => setNavigationHeight());
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="alert-stack">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alert-stack {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,13 @@
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const hideFooter = $derived.by(() => {
|
||||
const endings = ['table-[table]', 'table-[table]/columns', 'table-[table]/indexes'];
|
||||
const endings = [
|
||||
'collection-[collection]',
|
||||
'collection-[collection]/indexes',
|
||||
'table-[table]',
|
||||
'table-[table]/columns',
|
||||
'table-[table]/indexes'
|
||||
];
|
||||
return endings.some((end) => page.route.id?.endsWith(end));
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getContext, onDestroy, onMount } from 'svelte';
|
||||
import { isTabletViewport } from '$lib/stores/viewport';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
@@ -17,6 +17,9 @@
|
||||
export let type: 'info' | 'success' | 'warning' | 'error' | 'default' = 'info';
|
||||
export let dismissible = false;
|
||||
|
||||
// When inside an AlertStack, layout management is handled by the stack wrapper
|
||||
const isInStack: boolean = getContext('isInAlertStack') ?? false;
|
||||
|
||||
/**
|
||||
* This is needed because when the
|
||||
* slot's height gets changed, sometimes the update doesn't come through!
|
||||
@@ -29,14 +32,14 @@
|
||||
function setNavigationHeight() {
|
||||
const alertHeight = container ? container.getBoundingClientRect().height : 0;
|
||||
const header: HTMLHeadingElement = document.querySelector('main > header');
|
||||
const sidebar: HTMLElement = document.querySelector('main > div > nav');
|
||||
const contentSection: HTMLElement = document.querySelector('main > div > section');
|
||||
const sidebar: HTMLElement = document.querySelector('main nav');
|
||||
const contentSection: HTMLElement = document.querySelector('main .main-content');
|
||||
|
||||
if (alertHeight) {
|
||||
// for sidebar and sub-navigation!
|
||||
bannerSpacing.set(`${alertHeight}px`);
|
||||
} else {
|
||||
bannerSpacing.set(undefined);
|
||||
bannerSpacing.set(null);
|
||||
}
|
||||
|
||||
if (header) {
|
||||
@@ -57,28 +60,33 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (container) {
|
||||
if (!isInStack && container) {
|
||||
resizeObserver = new ResizeObserver(setNavigationHeight);
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
container = null;
|
||||
setNavigationHeight();
|
||||
if (!isInStack) {
|
||||
container = null;
|
||||
setNavigationHeight();
|
||||
|
||||
// remove observer!
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
// remove observer!
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterNavigate(() => setNavigationHeight());
|
||||
afterNavigate(() => {
|
||||
if (!isInStack) setNavigationHeight();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section
|
||||
bind:this={container}
|
||||
class="alert is-action is-action-and-top-sticky u-sep-block-end"
|
||||
class:in-stack={isInStack}
|
||||
class:is-success={type === 'success'}
|
||||
class:is-warning={type === 'warning'}
|
||||
class:is-danger={type === 'error'}
|
||||
@@ -125,6 +133,11 @@
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.alert.in-stack {
|
||||
position: static;
|
||||
z-index: unset;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export { default as Activity } from './activity.svelte';
|
||||
export { default as Progress } from './progress.svelte';
|
||||
export { default as GridHeader } from './gridHeader.svelte';
|
||||
export { default as ContainerHeader } from './containerHeader.svelte';
|
||||
export { default as AlertStack } from './alertStack.svelte';
|
||||
export { default as HeaderAlert } from './headerAlert.svelte';
|
||||
export { default as ContainerButton } from './containerButton.svelte';
|
||||
export { default as WizardSecondaryContainer } from './wizardSecondaryContainer.svelte';
|
||||
|
||||
@@ -74,6 +74,10 @@
|
||||
|
||||
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
beforeNavigate((nav) => {
|
||||
if (running) {
|
||||
complete();
|
||||
}
|
||||
|
||||
if (progressBarStartTimeout) {
|
||||
clearTimeout(progressBarStartTimeout);
|
||||
progressBarStartTimeout = null;
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
import { Navbar, Sidebar } from '$lib/components';
|
||||
import { isNewWizardStatusOpen, wizard } from '$lib/stores/wizard';
|
||||
import { activeHeaderAlert } from '$routes/(console)/store';
|
||||
import AlertStack from './alertStack.svelte';
|
||||
import ImpersonationBanner from '$lib/components/impersonation/banner.svelte';
|
||||
import {
|
||||
impersonationRevision,
|
||||
readImpersonationTargetUserId
|
||||
} from '$lib/appwrite/impersonation';
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { showSubNavigation, showOnboardingAnimation } from '$lib/stores/layout';
|
||||
@@ -28,6 +34,13 @@
|
||||
|
||||
$: activeProject = selectedProject && page.params.project ? selectedProject : null;
|
||||
|
||||
let isImpersonating = !!readImpersonationTargetUserId();
|
||||
$: {
|
||||
void $impersonationRevision;
|
||||
isImpersonating = !!readImpersonationTargetUserId();
|
||||
}
|
||||
$: hasAnyAlert = isImpersonating || !!$activeHeaderAlert?.show;
|
||||
|
||||
// variables
|
||||
let yOnMenuOpen: number;
|
||||
let showAccountMenu = false;
|
||||
@@ -214,16 +227,22 @@
|
||||
|
||||
<svelte:body use:style={$bodyStyle} />
|
||||
|
||||
{#if $activeHeaderAlert?.show && !$isNewWizardStatusOpen}
|
||||
<svelte:component this={$activeHeaderAlert.component} />
|
||||
{#if hasAnyAlert && !$isNewWizardStatusOpen}
|
||||
<AlertStack>
|
||||
<!-- ImpersonationBanner self-manages its own visibility via its internal {#if isImpersonating} guard -->
|
||||
<ImpersonationBanner />
|
||||
{#if $activeHeaderAlert?.show}
|
||||
<svelte:component this={$activeHeaderAlert.component} />
|
||||
{/if}
|
||||
</AlertStack>
|
||||
{/if}
|
||||
|
||||
<main
|
||||
class:has-alert={$activeHeaderAlert?.show}
|
||||
class:has-alert={hasAnyAlert}
|
||||
class:is-open={$showSubNavigation}
|
||||
class:is-sidebar-open={$isSidebarOpen}
|
||||
class:u-hide={$wizard.show || $wizard.cover}
|
||||
class:is-fixed-layout={$activeHeaderAlert?.show}
|
||||
class:is-fixed-layout={hasAnyAlert}
|
||||
class:no-header={!showHeader || $showOnboardingAnimation}
|
||||
style:--p-side-size={sideSize}>
|
||||
{#if showHeader && !$showOnboardingAnimation}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { type Models, AuthMethod as AuthMethodEnum } from '@appwrite.io/console';
|
||||
import { type Models, MethodId } from '@appwrite.io/console';
|
||||
|
||||
export type AuthMethod = {
|
||||
label: string;
|
||||
method: AuthMethodEnum;
|
||||
method: MethodId;
|
||||
value: boolean | null;
|
||||
};
|
||||
|
||||
@@ -11,37 +11,37 @@ const setAuthMethod = (project: Models.Project): AuthMethod[] => {
|
||||
return [
|
||||
{
|
||||
label: 'Email/Password',
|
||||
method: AuthMethodEnum.Emailpassword,
|
||||
method: MethodId.Emailpassword,
|
||||
value: project?.authEmailPassword
|
||||
},
|
||||
{
|
||||
label: 'Phone',
|
||||
method: AuthMethodEnum.Phone,
|
||||
method: MethodId.Phone,
|
||||
value: project?.authPhone
|
||||
},
|
||||
{
|
||||
label: 'Magic URL',
|
||||
method: AuthMethodEnum.Magicurl,
|
||||
method: MethodId.Magicurl,
|
||||
value: project?.authUsersAuthMagicURL
|
||||
},
|
||||
{
|
||||
label: 'Email OTP',
|
||||
method: AuthMethodEnum.Emailotp,
|
||||
method: MethodId.Emailotp,
|
||||
value: project?.authEmailOtp
|
||||
},
|
||||
{
|
||||
label: 'Anonymous',
|
||||
method: AuthMethodEnum.Anonymous,
|
||||
method: MethodId.Anonymous,
|
||||
value: project?.authAnonymous
|
||||
},
|
||||
{
|
||||
label: 'Team Invites',
|
||||
method: AuthMethodEnum.Invites,
|
||||
method: MethodId.Invites,
|
||||
value: project?.authInvites
|
||||
},
|
||||
{
|
||||
label: 'JWT',
|
||||
method: AuthMethodEnum.Jwt,
|
||||
method: MethodId.Jwt,
|
||||
value: project?.authJWT
|
||||
}
|
||||
];
|
||||
|
||||
@@ -518,7 +518,7 @@ export async function checkPaymentAuthorizationRequired(org: Models.Organization
|
||||
importance: 8
|
||||
});
|
||||
}
|
||||
activeHeaderAlert.set(headerAlert.get());
|
||||
activeHeaderAlert.set(headerAlert.getExcluding('impersonation'));
|
||||
|
||||
actionRequiredInvoices.set(invoices);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,18 @@ export const backupsBannerId = 'banner:databaseBackups';
|
||||
|
||||
export const showPolicyAlert = writable<boolean>(false);
|
||||
|
||||
type SubNavigationEntity = {
|
||||
$id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type SubNavigationEvent =
|
||||
| {
|
||||
type: 'entity-created';
|
||||
entity: SubNavigationEntity;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export async function checkForDatabaseBackupPolicies(
|
||||
region: string,
|
||||
projectId: string,
|
||||
@@ -50,17 +62,17 @@ export async function checkForDatabaseBackupPolicies(
|
||||
}
|
||||
|
||||
function createSubNavigationTrigger() {
|
||||
const subscribers = new Set<() => void>();
|
||||
const subscribers = new Set<(event?: SubNavigationEvent) => void>();
|
||||
|
||||
return {
|
||||
subscribe: (callback: () => void) => {
|
||||
subscribe: (callback: (event?: SubNavigationEvent) => void) => {
|
||||
subscribers.add(callback);
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
};
|
||||
},
|
||||
update: () => {
|
||||
subscribers.forEach((callback) => callback());
|
||||
update: (event?: SubNavigationEvent) => {
|
||||
subscribers.forEach((callback) => callback(event));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Component } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { get as getStore, writable } from 'svelte/store';
|
||||
|
||||
export type HeaderAlert = {
|
||||
id: string;
|
||||
@@ -13,13 +13,11 @@ export type HeaderAlertStore = {
|
||||
};
|
||||
|
||||
function createHeaderAlertStore() {
|
||||
const { subscribe, update, set } = writable<HeaderAlertStore>({
|
||||
components: []
|
||||
});
|
||||
const store = writable<HeaderAlertStore>({ components: [] });
|
||||
const { subscribe, update } = store;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
add: (component: HeaderAlert) => {
|
||||
update((n) => {
|
||||
if (n.components.some((c) => c.id === component.id)) return n;
|
||||
@@ -30,22 +28,34 @@ function createHeaderAlertStore() {
|
||||
}
|
||||
});
|
||||
},
|
||||
get: (): HeaderAlert => {
|
||||
//return highest importance component
|
||||
let component = {
|
||||
id: '',
|
||||
show: false,
|
||||
component: null,
|
||||
importance: 0
|
||||
};
|
||||
updateShow: (id: string, show: boolean) => {
|
||||
update((n) => {
|
||||
n.components.forEach((c) => {
|
||||
if (c.importance > component.importance) {
|
||||
component = c;
|
||||
}
|
||||
});
|
||||
const component = n.components.find((c) => c.id === id);
|
||||
if (!component || component.show === show) return n;
|
||||
|
||||
component.show = show;
|
||||
n.components = [...n.components];
|
||||
return n;
|
||||
});
|
||||
},
|
||||
get: (): HeaderAlert => {
|
||||
// return highest importance visible component
|
||||
let component = { id: '', show: false, component: null, importance: 0 };
|
||||
getStore(store).components.forEach((c) => {
|
||||
if (c.show && c.importance > component.importance) {
|
||||
component = c;
|
||||
}
|
||||
});
|
||||
return component as HeaderAlert;
|
||||
},
|
||||
getExcluding: (excludeId: string): HeaderAlert => {
|
||||
// return highest importance visible component, excluding a specific id
|
||||
let component = { id: '', show: false, component: null, importance: 0 };
|
||||
getStore(store).components.forEach((c) => {
|
||||
if (c.show && c.id !== excludeId && c.importance > component.importance) {
|
||||
component = c;
|
||||
}
|
||||
});
|
||||
return component as HeaderAlert;
|
||||
}
|
||||
};
|
||||
|
||||
+42
-34
@@ -15,7 +15,7 @@ export type MigrationResource =
|
||||
|
||||
// Appwrite enum is the superset of all provider resources — used as a
|
||||
// provider-agnostic reference. The addResource guard filters by provider.
|
||||
export const ResourceType = AppwriteMigrationResource;
|
||||
export const MigrationResources = AppwriteMigrationResource;
|
||||
|
||||
type ProviderResourceMap = {
|
||||
appwrite: AppwriteMigrationResource[];
|
||||
@@ -107,46 +107,46 @@ export const migrationFormToResources = <P extends Provider>(
|
||||
};
|
||||
|
||||
if (formData.users.root) {
|
||||
addResource(ResourceType.User);
|
||||
addResource(MigrationResources.User);
|
||||
if (formData.users.teams) {
|
||||
addResource(ResourceType.Team);
|
||||
addResource(ResourceType.Membership);
|
||||
addResource(MigrationResources.Team);
|
||||
addResource(MigrationResources.Membership);
|
||||
}
|
||||
}
|
||||
if (formData.databases.root) {
|
||||
addResource(ResourceType.Database);
|
||||
addResource(ResourceType.Table);
|
||||
addResource(ResourceType.Column);
|
||||
addResource(ResourceType.Index);
|
||||
addResource(MigrationResources.Database);
|
||||
addResource(MigrationResources.Table);
|
||||
addResource(MigrationResources.Column);
|
||||
addResource(MigrationResources.Index);
|
||||
}
|
||||
if (formData.databases.rows) {
|
||||
addResource(ResourceType.Row);
|
||||
addResource(MigrationResources.Row);
|
||||
}
|
||||
if (formData.storage.root) {
|
||||
addResource(ResourceType.Bucket);
|
||||
addResource(ResourceType.File);
|
||||
addResource(MigrationResources.Bucket);
|
||||
addResource(MigrationResources.File);
|
||||
}
|
||||
if (formData.functions.root) {
|
||||
addResource(ResourceType.Function);
|
||||
addResource(ResourceType.Environmentvariable);
|
||||
addResource(MigrationResources.Function);
|
||||
addResource(MigrationResources.Environmentvariable);
|
||||
if (formData.functions.deploymentInactive) {
|
||||
addResource(ResourceType.Deployment);
|
||||
addResource(MigrationResources.Deployment);
|
||||
}
|
||||
}
|
||||
if (formData.sites.root) {
|
||||
addResource(ResourceType.Site);
|
||||
addResource(ResourceType.Sitevariable);
|
||||
addResource(MigrationResources.Site);
|
||||
addResource(MigrationResources.Sitevariable);
|
||||
if (formData.sites.deploymentInactive) {
|
||||
addResource(ResourceType.Sitedeployment);
|
||||
addResource(MigrationResources.Sitedeployment);
|
||||
}
|
||||
}
|
||||
if (formData.messaging.root) {
|
||||
addResource(ResourceType.Provider);
|
||||
addResource(ResourceType.Topic);
|
||||
addResource(ResourceType.Subscriber);
|
||||
addResource(MigrationResources.Provider);
|
||||
addResource(MigrationResources.Topic);
|
||||
addResource(MigrationResources.Subscriber);
|
||||
}
|
||||
if (formData.messaging.messages) {
|
||||
addResource(ResourceType.Message);
|
||||
addResource(MigrationResources.Message);
|
||||
}
|
||||
|
||||
return resources as ProviderResourceMap[P];
|
||||
@@ -175,45 +175,53 @@ export const isVersionAtLeast = (version: string, atLeast: string) => {
|
||||
|
||||
export const resourcesToMigrationForm = (resources: MigrationResource[]): MigrationFormData => {
|
||||
const formData = { ...initialFormData };
|
||||
if (resources.includes(ResourceType.User)) {
|
||||
if (resources.includes(MigrationResources.User)) {
|
||||
formData.users.root = true;
|
||||
}
|
||||
if (
|
||||
includesAll(resources, [ResourceType.Team, ResourceType.Membership] as MigrationResource[])
|
||||
includesAll(resources, [
|
||||
MigrationResources.Team,
|
||||
MigrationResources.Membership
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.users.teams = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Database)) {
|
||||
if (resources.includes(MigrationResources.Database)) {
|
||||
formData.databases.root = true;
|
||||
}
|
||||
if (
|
||||
includesAll(resources, [
|
||||
ResourceType.Table,
|
||||
ResourceType.Column,
|
||||
ResourceType.Row
|
||||
MigrationResources.Table,
|
||||
MigrationResources.Column,
|
||||
MigrationResources.Row
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.databases.rows = true;
|
||||
}
|
||||
if (includesAll(resources, [ResourceType.Bucket, ResourceType.File] as MigrationResource[])) {
|
||||
if (
|
||||
includesAll(resources, [
|
||||
MigrationResources.Bucket,
|
||||
MigrationResources.File
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.storage.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Function)) {
|
||||
if (resources.includes(MigrationResources.Function)) {
|
||||
formData.functions.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Deployment)) {
|
||||
if (resources.includes(MigrationResources.Deployment)) {
|
||||
formData.functions.deploymentInactive = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Site)) {
|
||||
if (resources.includes(MigrationResources.Site)) {
|
||||
formData.sites.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Sitedeployment)) {
|
||||
if (resources.includes(MigrationResources.Sitedeployment)) {
|
||||
formData.sites.deploymentInactive = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Provider)) {
|
||||
if (resources.includes(MigrationResources.Provider)) {
|
||||
formData.messaging.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Message)) {
|
||||
if (resources.includes(MigrationResources.Message)) {
|
||||
formData.messaging.messages = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Pathname, RouteId, RouteParams } from '$app/types';
|
||||
import type { Pathname, ResolvedPathname, RouteId, RouteParams } from '$app/types';
|
||||
|
||||
// taken directly from svelte's source!
|
||||
type ResolveArgs<T extends RouteId | Pathname> = T extends RouteId
|
||||
@@ -20,7 +20,10 @@ export function withPath(base: string, ...parts: string[]) {
|
||||
return [normalizedBase, ...normalizedParts].join('/');
|
||||
}
|
||||
|
||||
export function resolveRoute<T extends RouteId>(route: T, params?: Record<string, string>) {
|
||||
export function resolveRoute<T extends RouteId>(
|
||||
route: T,
|
||||
params?: Record<string, string>
|
||||
): ResolvedPathname {
|
||||
// type cast is necessary here!
|
||||
const resolveArgs = params ? ([route, params] as [T, RouteParams<T>]) : [route];
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { Component } from 'svelte';
|
||||
import Apple from '$routes/(console)/project-[region]-[project]/auth/(providers)/appleOAuth.svelte';
|
||||
import Auth0 from '$routes/(console)/project-[region]-[project]/auth/(providers)/auth0OAuth.svelte';
|
||||
import Authentik from '$routes/(console)/project-[region]-[project]/auth/(providers)/authentikOAuth.svelte';
|
||||
import GitLab from '$routes/(console)/project-[region]-[project]/auth/(providers)/gitlabOAuth.svelte';
|
||||
import Google from '$routes/(console)/project-[region]-[project]/auth/(providers)/googleOAuth.svelte';
|
||||
import Main from '$routes/(console)/project-[region]-[project]/auth/(providers)/mainOAuth.svelte';
|
||||
import Microsoft from '$routes/(console)/project-[region]-[project]/auth/(providers)/microsoftOAuth.svelte';
|
||||
import Oidc from '$routes/(console)/project-[region]-[project]/auth/(providers)/oidcOAuth.svelte';
|
||||
import Okta from '$routes/(console)/project-[region]-[project]/auth/(providers)/oktaOAuth.svelte';
|
||||
|
||||
export type Provider = {
|
||||
name: string;
|
||||
@@ -28,19 +20,19 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
name: 'Apple',
|
||||
icon: 'apple',
|
||||
docs: 'https://developer.apple.com/sign-in-with-apple/',
|
||||
component: Apple
|
||||
component: Main
|
||||
},
|
||||
auth0: {
|
||||
name: 'Auth0',
|
||||
icon: 'auth0',
|
||||
docs: 'https://auth0.com/developers',
|
||||
component: Auth0
|
||||
component: Main
|
||||
},
|
||||
authentik: {
|
||||
name: 'Authentik',
|
||||
icon: 'authentik',
|
||||
docs: 'https://goauthentik.io/integrations/sources/oauth/',
|
||||
component: Authentik
|
||||
component: Main
|
||||
},
|
||||
autodesk: {
|
||||
name: 'Autodesk',
|
||||
@@ -108,6 +100,12 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
docs: 'https://www.figma.com/developers/api#access-tokens',
|
||||
component: Main
|
||||
},
|
||||
fusionauth: {
|
||||
name: 'FusionAuth',
|
||||
icon: 'fusionauth',
|
||||
docs: 'https://fusionauth.io/docs/apis/identity-providers/oauth2',
|
||||
component: Main
|
||||
},
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
icon: 'github',
|
||||
@@ -125,21 +123,33 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
name: 'GitLab',
|
||||
icon: 'gitlab',
|
||||
docs: 'https://docs.gitlab.com/ee/api/',
|
||||
component: GitLab
|
||||
component: Main
|
||||
},
|
||||
google: {
|
||||
name: 'Google',
|
||||
icon: 'google',
|
||||
docs: 'https://support.google.com/googleapi/answer/6158849',
|
||||
component: Google
|
||||
component: Main
|
||||
},
|
||||
googleImagine: {
|
||||
name: 'Google',
|
||||
icon: 'google',
|
||||
docs: 'https://support.google.com/googleapi/answer/6158849',
|
||||
component: Google,
|
||||
component: Main,
|
||||
internal: true
|
||||
},
|
||||
keycloak: {
|
||||
name: 'Keycloak',
|
||||
icon: 'keycloak',
|
||||
docs: 'https://www.keycloak.org/securing-apps/oidc-layers',
|
||||
component: Main
|
||||
},
|
||||
kick: {
|
||||
name: 'Kick',
|
||||
icon: 'kick',
|
||||
docs: 'https://docs.kick.com/getting-started/using-the-api',
|
||||
component: Main
|
||||
},
|
||||
linkedin: {
|
||||
name: 'LinkedIn',
|
||||
icon: 'linkedin',
|
||||
@@ -150,7 +160,7 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
name: 'Microsoft',
|
||||
icon: 'microsoft',
|
||||
docs: 'https://developer.microsoft.com/en-us/',
|
||||
component: Microsoft
|
||||
component: Main
|
||||
},
|
||||
notion: {
|
||||
name: 'Notion',
|
||||
@@ -162,13 +172,13 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
name: 'OIDC',
|
||||
icon: 'oidc',
|
||||
docs: 'https://openid.net/connect/faq/',
|
||||
component: Oidc
|
||||
component: Main
|
||||
},
|
||||
okta: {
|
||||
name: 'Okta',
|
||||
icon: 'okta',
|
||||
docs: 'https://developer.okta.com',
|
||||
component: Okta
|
||||
component: Main
|
||||
},
|
||||
paypal: {
|
||||
name: 'Paypal',
|
||||
@@ -176,7 +186,7 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
docs: 'https://developer.paypal.com/docs/api/overview/',
|
||||
component: Main
|
||||
},
|
||||
paypalsandbox: {
|
||||
paypalSandbox: {
|
||||
name: 'Paypal Sandbox',
|
||||
icon: 'paypal',
|
||||
docs: 'https://developer.paypal.com/docs/api/overview/',
|
||||
@@ -248,12 +258,6 @@ export const oAuthProviders: Record<string, Provider> = {
|
||||
docs: 'https://developer.yahoo.com/oauth2/guide/flows_authcode/',
|
||||
component: Main
|
||||
},
|
||||
yammer: {
|
||||
name: 'Yammer',
|
||||
icon: 'yammer',
|
||||
docs: 'https://developer.yammer.com/docs/oauth-2',
|
||||
component: Main
|
||||
},
|
||||
yandex: {
|
||||
name: 'Yandex',
|
||||
icon: 'yandex',
|
||||
|
||||
@@ -32,7 +32,11 @@ type ConsolePreferencesStore = {
|
||||
[key: string]: ConsolePreferences['columns'];
|
||||
};
|
||||
displayNames?: {
|
||||
[key: string]: TeamPreferences['names'];
|
||||
[key: string]:
|
||||
| TeamPreferences['names']
|
||||
| {
|
||||
[key: string]: TeamPreferences['names'];
|
||||
};
|
||||
};
|
||||
columnOrder?: {
|
||||
[key: string]: TeamPreferences['order'];
|
||||
@@ -213,19 +217,31 @@ function createPreferences() {
|
||||
return n;
|
||||
}),
|
||||
|
||||
deleteTableDetails: async (orgId: string, tableId: string) => {
|
||||
// `databaseType` fallback for legacy cases.
|
||||
deleteEntityDetails: async (
|
||||
orgId: string,
|
||||
entityId: string,
|
||||
databaseType: string = 'tables'
|
||||
) => {
|
||||
// remove from account preferences
|
||||
const removeCustomTableColumns = updateAndSync((n) => {
|
||||
n = ensureObjectProperty(n, 'tables');
|
||||
delete n.tables[tableId];
|
||||
n = ensureObjectProperty(n, databaseType);
|
||||
delete n.tables[entityId];
|
||||
return n;
|
||||
});
|
||||
|
||||
delete teamPreferences?.displayNames?.[tableId];
|
||||
delete teamPreferences?.columnOrder?.[tableId];
|
||||
delete teamPreferences?.columnWidths?.[tableId];
|
||||
delete teamPreferences?.columnWidths?.[tableId + '#columns'];
|
||||
delete teamPreferences?.columnWidths?.[tableId + '#indexes'];
|
||||
delete teamPreferences?.columnOrder?.[entityId];
|
||||
delete teamPreferences?.columnWidths?.[entityId];
|
||||
delete teamPreferences?.columnWidths?.[entityId + '#columns'];
|
||||
delete teamPreferences?.columnWidths?.[entityId + '#indexes'];
|
||||
|
||||
if (teamPreferences.displayNames?.[databaseType]?.[entityId]) {
|
||||
// new structure
|
||||
delete teamPreferences?.displayNames?.[databaseType]?.[entityId];
|
||||
} else {
|
||||
// legacy structure
|
||||
delete teamPreferences?.displayNames?.[entityId];
|
||||
}
|
||||
|
||||
const removeTablePreferences = sdk.forConsole.teams.updatePrefs({
|
||||
teamId: orgId,
|
||||
@@ -237,18 +253,32 @@ function createPreferences() {
|
||||
|
||||
loadTeamPrefs: loadTeamPreferences,
|
||||
|
||||
getDisplayNames: (tableId: string) => {
|
||||
const names = teamPreferences?.displayNames?.[tableId];
|
||||
getDisplayNames: (entityId: string, databaseType: string = null) => {
|
||||
let names = teamPreferences?.displayNames?.[entityId];
|
||||
if (databaseType) {
|
||||
names = teamPreferences?.displayNames?.[databaseType]?.[entityId];
|
||||
}
|
||||
|
||||
return Array.isArray(names) && names.length > 0 ? names : ['$id'];
|
||||
},
|
||||
|
||||
setDisplayNames: async (
|
||||
orgId: string,
|
||||
tableId: string,
|
||||
displayNames: TeamPreferences['names']
|
||||
entityId: string,
|
||||
displayNames: TeamPreferences['names'],
|
||||
databaseType: string = null
|
||||
) => {
|
||||
teamPreferences = ensureObjectProperty(teamPreferences, 'displayNames');
|
||||
teamPreferences.displayNames[tableId] = displayNames;
|
||||
if (!databaseType) {
|
||||
// legacy!
|
||||
teamPreferences.displayNames[entityId] = displayNames;
|
||||
} else {
|
||||
teamPreferences.displayNames = ensureObjectProperty(
|
||||
teamPreferences.displayNames,
|
||||
databaseType
|
||||
);
|
||||
teamPreferences.displayNames[databaseType][entityId] = displayNames;
|
||||
}
|
||||
|
||||
await sdk.forConsole.teams.updatePrefs({
|
||||
teamId: orgId,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export function getIconFromRuntime(runtime: string) {
|
||||
export function getIconFromRuntime(runtime?: string) {
|
||||
switch (true) {
|
||||
case !runtime:
|
||||
return undefined;
|
||||
case runtime.includes('node'):
|
||||
return 'node';
|
||||
case runtime.includes('php'):
|
||||
@@ -18,6 +20,18 @@ export function getIconFromRuntime(runtime: string) {
|
||||
return 'deno';
|
||||
case runtime.includes('flutter'):
|
||||
return 'flutter';
|
||||
case runtime.includes('dotnet'):
|
||||
return 'dotnet';
|
||||
case runtime.includes('java'):
|
||||
return 'java';
|
||||
case runtime.includes('swift'):
|
||||
return 'swift';
|
||||
case runtime.includes('kotlin'):
|
||||
return 'kotlin';
|
||||
case runtime.includes('cpp'):
|
||||
return 'cpp';
|
||||
case runtime.includes('rust'):
|
||||
return 'rust';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+22
-5
@@ -1,4 +1,5 @@
|
||||
import { isMultiRegionSupported, VARS } from '$lib/system';
|
||||
import { registerImpersonationClients, restoreImpersonation } from '$lib/appwrite/impersonation';
|
||||
import {
|
||||
Account,
|
||||
Assistant,
|
||||
@@ -23,10 +24,11 @@ import {
|
||||
Tokens,
|
||||
TablesDB,
|
||||
Domains,
|
||||
DocumentsDB,
|
||||
Webhooks,
|
||||
/*DocumentsDB,*/
|
||||
Realtime,
|
||||
Organizations
|
||||
Organizations,
|
||||
VectorsDB
|
||||
} from '@appwrite.io/console';
|
||||
import { buildRegionalV1Endpoint } from '$lib/helpers/apiEndpoint';
|
||||
import { Sources } from '$lib/sdk/sources';
|
||||
@@ -67,6 +69,7 @@ function createConsoleSdk(client: Client) {
|
||||
const endpoint = getApiEndpoint();
|
||||
|
||||
const clientConsole = new Client();
|
||||
const clientConsoleOperator = new Client();
|
||||
const scopedConsoleClient = new Client();
|
||||
|
||||
const clientProject = new Client();
|
||||
@@ -75,9 +78,18 @@ const clientRealtime = new Client();
|
||||
if (!building) {
|
||||
scopedConsoleClient.setProject('console');
|
||||
clientConsole.setEndpoint(endpoint).setProject('console');
|
||||
clientConsoleOperator.setEndpoint(endpoint).setProject('console');
|
||||
|
||||
clientProject.setEndpoint(endpoint).setMode('admin');
|
||||
clientRealtime.setEndpoint(endpoint).setProject('console');
|
||||
|
||||
registerImpersonationClients([
|
||||
clientConsole,
|
||||
scopedConsoleClient,
|
||||
clientProject,
|
||||
clientRealtime
|
||||
]);
|
||||
restoreImpersonation();
|
||||
}
|
||||
|
||||
const sdkForProject = {
|
||||
@@ -100,9 +112,10 @@ const sdkForProject = {
|
||||
migrations: new Migrations(clientProject),
|
||||
sites: new Sites(clientProject),
|
||||
tablesDB: new TablesDB(clientProject),
|
||||
/*documentsDB: new DocumentsDB(clientProject),*/
|
||||
console: new Console(clientProject), // for suggestions API
|
||||
webhooks: new Webhooks(clientProject)
|
||||
documentsDB: new DocumentsDB(clientProject),
|
||||
vectorsDB: new VectorsDB(clientProject),
|
||||
webhooks: new Webhooks(clientProject),
|
||||
console: new Console(clientProject) // for suggestions API
|
||||
};
|
||||
|
||||
export const realtime = {
|
||||
@@ -137,6 +150,10 @@ export const realtime = {
|
||||
|
||||
export const sdk = {
|
||||
forConsole: createConsoleSdk(clientConsole),
|
||||
// Operator-only console client. It is intentionally not registered with the
|
||||
// impersonation module so admin actions like switching targets stay scoped
|
||||
// to the real operator session.
|
||||
forConsoleAsOperator: createConsoleSdk(clientConsoleOperator),
|
||||
|
||||
forConsoleIn(region: string) {
|
||||
const regionAwareEndpoint = getApiEndpoint(region);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
|
||||
export const viewportWidth = writable(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
|
||||
export const isSmallViewport = writable(false);
|
||||
export const isTabletViewport = writable(false);
|
||||
@@ -6,6 +8,8 @@ export const isDesktopViewport = writable(false);
|
||||
|
||||
export function updateViewport() {
|
||||
if (typeof window !== 'undefined') {
|
||||
viewportWidth.set(window.innerWidth);
|
||||
|
||||
isSmallViewport.set(
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false
|
||||
);
|
||||
@@ -18,3 +22,7 @@ export function updateViewport() {
|
||||
isDesktopViewport.set(!get(isSmallViewport) && !get(isTabletViewport));
|
||||
}
|
||||
}
|
||||
|
||||
export function isViewPortWidthInRange(min: number, max: number) {
|
||||
return derived(viewportWidth, ($width) => $width >= min && $width <= max);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
type MigrationResource,
|
||||
providerResources,
|
||||
resourcesToMigrationForm,
|
||||
ResourceType
|
||||
MigrationResources
|
||||
} from '$lib/stores/migration';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
@@ -97,24 +97,27 @@
|
||||
|
||||
const shouldRenderGroup = (groupKey: string): boolean => {
|
||||
if (groupKey === 'storage') {
|
||||
return resources.includes(ResourceType.Bucket) && resources.includes(ResourceType.File);
|
||||
return (
|
||||
resources.includes(MigrationResources.Bucket) &&
|
||||
resources.includes(MigrationResources.File)
|
||||
);
|
||||
}
|
||||
|
||||
if (groupKey === 'functions') {
|
||||
return resources.includes(ResourceType.Function);
|
||||
return resources.includes(MigrationResources.Function);
|
||||
}
|
||||
|
||||
if (groupKey === 'sites') {
|
||||
return resources.includes(ResourceType.Site);
|
||||
return resources.includes(MigrationResources.Site);
|
||||
}
|
||||
|
||||
if (groupKey === 'messaging') {
|
||||
return resources.includes(ResourceType.Provider);
|
||||
return resources.includes(MigrationResources.Provider);
|
||||
}
|
||||
|
||||
const groupToResource: Record<string, MigrationResource> = {
|
||||
users: ResourceType.User,
|
||||
databases: ResourceType.Database
|
||||
users: MigrationResources.User,
|
||||
databases: MigrationResources.Database
|
||||
};
|
||||
const resource = groupToResource[groupKey];
|
||||
return resource ? resources.includes(resource) : false;
|
||||
@@ -140,7 +143,7 @@
|
||||
|
||||
<Layout.Stack gap="l">
|
||||
{#if error}
|
||||
<Alert.Inline status="error" title="Couldn’t load resources">
|
||||
<Alert.Inline status="error" title="Couldn't load resources">
|
||||
{#if migrationType === 'provider'}
|
||||
Please double-check your credentials from the previous step and try again.
|
||||
{:else}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ResourceForm from './resource-form.svelte';
|
||||
import { requestedMigration } from '$routes/store';
|
||||
import { formData, provider, selectedProject, selectedRegion } from '.';
|
||||
import { ID, type Models, Query } from '@appwrite.io/console';
|
||||
import { ID, type Models, Query, AppwriteMigrationResource } from '@appwrite.io/console';
|
||||
import { InputSelect, InputText } from '$lib/elements/forms';
|
||||
import {
|
||||
Button,
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
function getProjectName(): string {
|
||||
return isExisting ? currentSelectedProject.name : newProjName || 'Appwrite project';
|
||||
return isExisting ? currentSelectedProject.name : newProjName || 'New project';
|
||||
}
|
||||
|
||||
async function createNewProject() {
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
try {
|
||||
await projectSdkInstance.migrations.createAppwriteMigration({
|
||||
resources,
|
||||
resources: resources as AppwriteMigrationResource[],
|
||||
endpoint: $provider.endpoint,
|
||||
projectId: $provider.projectID,
|
||||
apiKey: $provider.apiKey
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
calculateTrialDay(org);
|
||||
}
|
||||
}
|
||||
$activeHeaderAlert = headerAlert.get();
|
||||
$activeHeaderAlert = headerAlert.getExcluding('impersonation');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@
|
||||
$registerSearchers(orgSearcher, projectsSearcher);
|
||||
|
||||
afterUpdate(() => {
|
||||
$activeHeaderAlert = headerAlert.get();
|
||||
$activeHeaderAlert = headerAlert.getExcluding('impersonation');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import { isCloud } from '$lib/system';
|
||||
import { isSameDay } from '$lib/helpers/date';
|
||||
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
|
||||
import ClaudeCodePlugin from '$lib/images/promos/claude-code-plugin.png';
|
||||
import TerraformProvider from '$lib/images/promos/terraform-provider.png';
|
||||
|
||||
const listOfPromotions: BottomModalAlertItem[] = [];
|
||||
|
||||
if (isCloud) {
|
||||
const claudePluginPromo: BottomModalAlertItem = {
|
||||
id: 'modal:claude_plugin_announcement',
|
||||
const terraformProviderPromo: BottomModalAlertItem = {
|
||||
id: 'modal:terraform_provider_announcement',
|
||||
src: {
|
||||
dark: ClaudeCodePlugin,
|
||||
light: ClaudeCodePlugin
|
||||
dark: TerraformProvider,
|
||||
light: TerraformProvider
|
||||
},
|
||||
title: 'Announcing the Appwrite Claude plugin',
|
||||
message: 'Build, manage, and ship Appwrite projects without leaving Claude Code.',
|
||||
title: 'Introducing Terraform support for Appwrite projects',
|
||||
message:
|
||||
'Manage your entire Appwrite project as code with the official Terraform provider.',
|
||||
plan: 'free',
|
||||
importance: 8,
|
||||
scope: 'everywhere',
|
||||
cta: {
|
||||
text: 'Read announcement',
|
||||
link: () => 'https://appwrite.io/blog/post/announcing-appwrite-claude-code-plugin',
|
||||
link: () => 'https://appwrite.io/blog/post/introducing-terraform-provider-for-appwrite',
|
||||
external: true,
|
||||
hideOnClick: true,
|
||||
skipUpgradeRedirect: true
|
||||
},
|
||||
show: true
|
||||
};
|
||||
listOfPromotions.push(claudePluginPromo);
|
||||
listOfPromotions.push(terraformProviderPromo);
|
||||
}
|
||||
|
||||
export function addBottomModalAlerts() {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
let projectId = ID.unique();
|
||||
let projectRegion = Region.Fra;
|
||||
let projectName = 'Appwrite Project';
|
||||
let projectName = 'New Project';
|
||||
|
||||
const projectIdForLog = projectId;
|
||||
|
||||
|
||||
@@ -148,6 +148,52 @@
|
||||
];
|
||||
}
|
||||
|
||||
function createBandwidthProgressData(
|
||||
totalBytes: number,
|
||||
realtimeBytes: number,
|
||||
maxGB: number
|
||||
): Array<{ size: number; color: string; tooltip?: { title: string; label: string } }> {
|
||||
if (maxGB <= 0) return [];
|
||||
|
||||
const maxBytes = maxGB * 1000 * 1000 * 1000;
|
||||
const percentage = Math.min((totalBytes / maxBytes) * 100, 100);
|
||||
const showRealtimeSegment = realtimeBytes > 0 && realtimeBytes <= totalBytes;
|
||||
const realtimePortion = showRealtimeSegment ? realtimeBytes : 0;
|
||||
const regularPortion = Math.max(0, totalBytes - realtimePortion);
|
||||
const regularSize = humanFileSize(regularPortion);
|
||||
const realtimeSize = humanFileSize(realtimePortion);
|
||||
|
||||
const segments: Array<{
|
||||
size: number;
|
||||
color: string;
|
||||
tooltip?: { title: string; label: string };
|
||||
}> = [];
|
||||
|
||||
if (regularPortion > 0) {
|
||||
segments.push({
|
||||
size: regularPortion,
|
||||
color: 'var(--bgcolor-neutral-invert)',
|
||||
tooltip: {
|
||||
title: `${percentage.toFixed(0)}% used`,
|
||||
label: `Bandwidth: ${regularSize.value} ${regularSize.unit}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (realtimePortion > 0) {
|
||||
segments.push({
|
||||
size: realtimePortion,
|
||||
color: 'var(--bgcolor-neutral-invert-weak)',
|
||||
tooltip: {
|
||||
title: 'Realtime bandwidth',
|
||||
label: `${realtimeSize.value} ${realtimeSize.unit}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function getResource(resources: Array<Models.UsageResources> | undefined, resourceId: string) {
|
||||
return resources?.find((resource) => resource.resourceId === resourceId);
|
||||
}
|
||||
@@ -282,9 +328,13 @@
|
||||
const projects = (currentAggregation?.breakdown || []).map((projectData) => {
|
||||
const resources = projectData.resources || [];
|
||||
const bandwidth = getResource(resources, 'bandwidth');
|
||||
const realtimeBandwidth = getResource(resources, 'realtimeBandwidth');
|
||||
const storage = getResource(resources, 'storage');
|
||||
const authPhone = getResource(resources, 'authPhone');
|
||||
|
||||
const bandwidthValue = bandwidth?.value || 0;
|
||||
const realtimeBandwidthValue = realtimeBandwidth?.value || 0;
|
||||
|
||||
return {
|
||||
id: `project-${projectData.$id}`,
|
||||
expandable: true,
|
||||
@@ -309,10 +359,31 @@
|
||||
),
|
||||
priceFormatter: ({ amount }) => formatCurrency(amount),
|
||||
progressFactory: ({ value, planLimit, hasLimit }) =>
|
||||
hasLimit ? createStorageProgressData(value, planLimit || 0) : [],
|
||||
hasLimit
|
||||
? createBandwidthProgressData(
|
||||
value,
|
||||
realtimeBandwidthValue,
|
||||
planLimit || 0
|
||||
)
|
||||
: [],
|
||||
maxFactory: ({ planLimit, hasLimit }) =>
|
||||
hasLimit ? (planLimit || 0) * 1000 * 1000 * 1000 : null
|
||||
}),
|
||||
...(realtimeBandwidthValue > bandwidthValue
|
||||
? [
|
||||
createRow({
|
||||
id: 'realtime-bandwidth',
|
||||
label: 'Realtime bandwidth',
|
||||
resource: realtimeBandwidth,
|
||||
usageFormatter: ({ value }) => {
|
||||
const size = humanFileSize(value);
|
||||
return `${size.value} ${size.unit}`;
|
||||
},
|
||||
priceFormatter: ({ amount }) => formatCurrency(amount),
|
||||
includeProgress: false
|
||||
})
|
||||
]
|
||||
: []),
|
||||
// standard resources (numeric)
|
||||
createResourceRow(
|
||||
'users',
|
||||
@@ -383,15 +454,6 @@
|
||||
getResource(resources, 'realtimeMessages'),
|
||||
currentPlan?.realtimeMessages
|
||||
),
|
||||
createRow({
|
||||
id: 'realtime-bandwidth',
|
||||
label: 'Realtime bandwidth',
|
||||
resource: getResource(resources, 'realtimeBandwidth'),
|
||||
usageFormatter: ({ value }) =>
|
||||
humanFileSize(value).value + humanFileSize(value).unit,
|
||||
priceFormatter: ({ amount }) => formatCurrency(amount),
|
||||
includeProgress: false
|
||||
}),
|
||||
createRow({
|
||||
id: 'sms',
|
||||
label: 'Phone OTP',
|
||||
|
||||
@@ -69,7 +69,11 @@
|
||||
?.$id;
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
if (from?.url) {
|
||||
const search = from.url.search;
|
||||
const pathname = from.url.pathname;
|
||||
previousPage = search ? `${pathname}${search}` : pathname;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
@@ -584,7 +584,7 @@
|
||||
color: '#68A3FE',
|
||||
tooltip: {
|
||||
title: 'Backups storage',
|
||||
label: `${Math.round(bytesToSize(data.organizationUsage.backupsStorageTotal, 'GB') * 100) / 100}MB`
|
||||
label: `${Math.round(bytesToSize(data.organizationUsage.backupsStorageTotal, 'GB') * 100) / 100}GB`
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -286,5 +286,9 @@
|
||||
:global(main:has([data-side-sheet-visible='true']) .layout-level-progress-bars) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:global(body:has(section .wizard) .layout-level-progress-bars) {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -106,7 +106,7 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
|
||||
|
||||
// Track console access for cloud projects (fire-and-forget, backend has 6-day cooldown).
|
||||
// Skip if paused — user must explicitly resume via the paused project modal.
|
||||
if (isCloud && browser && project.status !== 'paused') {
|
||||
if (isCloud && browser && project.status !== 'paused' && scopes.includes('projects.write')) {
|
||||
generateFingerprintToken()
|
||||
.then((fingerprint) => {
|
||||
sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint;
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputSwitch, InputText, InputTextarea } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import { Link, Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const projectId = page.params.project;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let appId: string = null;
|
||||
let enabled: boolean = null;
|
||||
let keyID: string = null;
|
||||
let teamID: string = null;
|
||||
let p8: string = null;
|
||||
let error: string;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
appId ??= provider.appId;
|
||||
enabled ??= provider.enabled;
|
||||
if (provider.secret) ({ keyID, teamID, p8 } = JSON.parse(provider.secret));
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: secret = keyID && teamID && p8 ? JSON.stringify({ keyID, teamID, p8 }) : provider.secret;
|
||||
</script>
|
||||
|
||||
<Modal {error} onSubmit={update} bind:show on:close title={`${provider.name} OAuth2 settings`}>
|
||||
<p slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<Link.Anchor
|
||||
class="link"
|
||||
href={oAuthProvider?.docs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
visit the docs.
|
||||
</Link.Anchor>
|
||||
</p>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="servicesID"
|
||||
label="Services ID"
|
||||
autofocus={true}
|
||||
placeholder="com.company.appname"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputText id="keyID" label="Key ID" placeholder="SHAB13ROFN" bind:value={keyID} required />
|
||||
<InputText id="teamID" label="Team ID" placeholder="ELA2CD3AED" bind:value={teamID} required />
|
||||
<InputTextarea id="p8" label="P8 File" placeholder="" bind:value={p8} required />
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!keyID ||
|
||||
!teamID ||
|
||||
!p8 ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Link, Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const projectId = page.params.project;
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let appId: string = null;
|
||||
let enabled: boolean = null;
|
||||
let clientSecret: string = null;
|
||||
let auth0Domain: string = null;
|
||||
let error: string;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
appId ??= provider.appId;
|
||||
enabled ??= provider.enabled;
|
||||
if (provider.secret) ({ clientSecret, auth0Domain } = JSON.parse(provider.secret));
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: secret =
|
||||
clientSecret && auth0Domain
|
||||
? JSON.stringify({ clientSecret, auth0Domain })
|
||||
: provider.secret;
|
||||
</script>
|
||||
|
||||
<Modal {error} bind:show onSubmit={update} on:close title={`${provider.name} OAuth2 settings`}>
|
||||
<svelte:fragment slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<Link.Anchor href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
|
||||
>visit the docs.</Link.Anchor>
|
||||
</svelte:fragment>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="clientID"
|
||||
label="Client ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="Client Secret"
|
||||
placeholder="Enter Client Secret"
|
||||
minlength={0}
|
||||
bind:value={clientSecret}
|
||||
required />
|
||||
<InputText
|
||||
id="domain"
|
||||
label="Auth0 Domain"
|
||||
placeholder="your-tenant.auth0.com"
|
||||
bind:value={auth0Domain}
|
||||
required />
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!clientSecret ||
|
||||
!auth0Domain ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Link, Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const projectId = page.params.project;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let appId: string = null;
|
||||
let enabled: boolean = null;
|
||||
let clientSecret: string = null;
|
||||
let authentikDomain: string = null;
|
||||
let error: string;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
appId ??= provider.appId;
|
||||
enabled ??= provider.enabled;
|
||||
if (provider.secret) ({ clientSecret, authentikDomain } = JSON.parse(provider.secret));
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: secret =
|
||||
clientSecret && authentikDomain
|
||||
? JSON.stringify({ clientSecret, authentikDomain })
|
||||
: provider.secret;
|
||||
</script>
|
||||
|
||||
<Modal {error} bind:show onSubmit={update} on:close title={`${provider.name} OAuth2 settings`}>
|
||||
<p slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<Link.Anchor
|
||||
class="link"
|
||||
href={oAuthProvider?.docs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">visit the docs.</Link.Anchor>
|
||||
</p>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="clientID"
|
||||
label="Client ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="Client Secret"
|
||||
placeholder="Enter Client Secret"
|
||||
minlength={0}
|
||||
bind:value={clientSecret}
|
||||
required />
|
||||
<InputText
|
||||
id="domain"
|
||||
label="Authentik Base-Domain"
|
||||
placeholder="Your Authentik domain"
|
||||
bind:value={authentikDomain}
|
||||
required />
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!clientSecret ||
|
||||
!authentikDomain ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Link, Alert } from '@appwrite.io/pink-svelte';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
|
||||
const projectId = page.params.project;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let appId: string = null;
|
||||
let enabled: boolean = null;
|
||||
let clientSecret: string = null;
|
||||
let endpoint: string = null;
|
||||
let error: string;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
appId ??= provider.appId;
|
||||
enabled ??= provider.enabled;
|
||||
if (provider.secret) ({ clientSecret, endpoint } = JSON.parse(provider.secret));
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: secret =
|
||||
clientSecret && endpoint ? JSON.stringify({ clientSecret, endpoint }) : provider.secret;
|
||||
</script>
|
||||
|
||||
<Modal {error} bind:show onSubmit={update} on:close title={`${provider.name} OAuth2 settings`}>
|
||||
<p slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<Link.Anchor href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
|
||||
>visit the docs.</Link.Anchor>
|
||||
</p>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="appID"
|
||||
label="App ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="App Secret"
|
||||
placeholder="Enter App Secret"
|
||||
minlength={0}
|
||||
bind:value={clientSecret}
|
||||
required />
|
||||
<InputText id="endpoint" label="Endpoint" placeholder="Your endpoint" bind:value={endpoint} />
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!clientSecret ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import { Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const projectId = page.params.project;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let enabled: boolean = null;
|
||||
let appId: string = null;
|
||||
let secret: string = null;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
enabled ??= provider.enabled;
|
||||
appId ??= provider.appId;
|
||||
secret ??= provider.secret;
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
let error: string;
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal {error} bind:show onSubmit={update} title="{provider.name} OAuth2 settings" on:close>
|
||||
<svelte:fragment slot="title">{provider.name} OAuth2 settings</svelte:fragment>
|
||||
|
||||
<p slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
|
||||
>visit the docs.</a>
|
||||
</p>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="appID"
|
||||
label="App ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="App Secret"
|
||||
placeholder="Enter App Secret"
|
||||
minlength={0}
|
||||
bind:value={secret}
|
||||
required />
|
||||
<Alert.Inline status="info">
|
||||
To complete the setup, create an OAuth2 client ID with "Web application" as the application
|
||||
type, then add this redirect URI to your {provider.name} configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!secret ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
+386
-39
@@ -1,35 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Button,
|
||||
InputPassword,
|
||||
InputSwitch,
|
||||
InputText,
|
||||
InputTextarea
|
||||
} from '$lib/elements/forms';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { Link, Alert } from '@appwrite.io/pink-svelte';
|
||||
import { OAuthProvider, type Models, type Models as ConsoleModels } from '@appwrite.io/console';
|
||||
import { oAuthProviders } from '$lib/stores/oauth-providers';
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Card,
|
||||
Divider,
|
||||
Icon,
|
||||
Layout,
|
||||
Link,
|
||||
Tag,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { IconDocument, IconPencil, IconUpload, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
|
||||
const projectId = page.params.project;
|
||||
const region = page.params.region;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
export let parameters: ConsoleModels.ConsoleOAuth2ProviderParameter[] = [];
|
||||
|
||||
let enabled: boolean = null;
|
||||
let appId: string = null;
|
||||
let secret: string = null;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
enabled ??= provider.enabled;
|
||||
appId ??= provider.appId;
|
||||
secret ??= provider.secret;
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
let enabled: boolean = provider.enabled;
|
||||
let appId: string | null = null;
|
||||
let providerKeyId: string | null = null;
|
||||
let fieldValues: Record<string, string | null> = {};
|
||||
let showSecretInput = false;
|
||||
let p8PasteMode: Record<string, boolean> = {};
|
||||
let error: string;
|
||||
let initializedProvider: Models.AuthProvider | null = null;
|
||||
let initialEnabled = false;
|
||||
let initialAppId: string | null = null;
|
||||
let initialDetailValues: Record<string, string> = {};
|
||||
|
||||
$: appIdParam = parameters.length >= 1 ? parameters[0] : null;
|
||||
$: additionalParams = parameters.slice(1);
|
||||
$: detailParams = additionalParams.filter((param) => !isSecretParam(param.$id));
|
||||
$: secretParams = additionalParams.filter((param) => isSecretParam(param.$id));
|
||||
$: basicDetailParams =
|
||||
provider?.key === OAuthProvider.Oidc
|
||||
? detailParams.filter((param) => !isOidcAdvancedParam(param.$id))
|
||||
: detailParams;
|
||||
$: advancedDetailParams =
|
||||
provider?.key === OAuthProvider.Oidc
|
||||
? detailParams.filter((param) => isOidcAdvancedParam(param.$id))
|
||||
: [];
|
||||
$: hasSecretInput = secretParams.some((param) => {
|
||||
const value = fieldValues[param.$id];
|
||||
return value !== null && value !== '';
|
||||
});
|
||||
$: hasDetailChanges = detailParams.some((param) => {
|
||||
return (
|
||||
normalizeFieldValue(fieldValues[param.$id]) !== (initialDetailValues[param.$id] ?? '')
|
||||
);
|
||||
});
|
||||
$: nothingChanged =
|
||||
enabled === initialEnabled &&
|
||||
normalizeFieldValue(appId) === normalizeFieldValue(initialAppId) &&
|
||||
!hasSecretInput &&
|
||||
!hasDetailChanges;
|
||||
$: oAuthProvider = oAuthProviders[provider.key];
|
||||
$: secretCardTitle =
|
||||
secretParams.length === 1 ? primaryName(secretParams[0]?.name ?? '') : 'Credentials';
|
||||
|
||||
$: if (provider && provider !== initializedProvider) {
|
||||
initializedProvider = provider;
|
||||
initialEnabled = provider.enabled;
|
||||
initialAppId = getInitialAppId();
|
||||
providerKeyId = getInitialFieldValue('keyId');
|
||||
initialDetailValues = Object.fromEntries(
|
||||
detailParams.map((param) => [param.$id, getInitialFieldValue(param.$id) ?? ''])
|
||||
);
|
||||
enabled = initialEnabled;
|
||||
appId = initialAppId;
|
||||
fieldValues = Object.fromEntries(
|
||||
additionalParams.map((param) => [param.$id, getInitialFieldValue(param.$id)])
|
||||
);
|
||||
p8PasteMode = {};
|
||||
showSecretInput = !appId || (provider.key === OAuthProvider.Apple && !providerKeyId);
|
||||
error = undefined;
|
||||
}
|
||||
|
||||
function primaryName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
function helperText(hint?: string | null): string | undefined {
|
||||
const value = hint?.trim();
|
||||
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getInitialFieldValue(id: string): string | null {
|
||||
const value = (provider as Record<string, unknown>)[id];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function getInitialAppId(): string | null {
|
||||
const raw = provider as Record<string, unknown>;
|
||||
const value =
|
||||
raw['clientId'] ??
|
||||
raw['appId'] ??
|
||||
raw['oauthClientId'] ??
|
||||
raw['oauth2ClientId'] ??
|
||||
raw['applicationId'] ??
|
||||
raw['serviceId'] ??
|
||||
raw['apiKey'] ??
|
||||
raw['publicKey'] ??
|
||||
raw['appKey'] ??
|
||||
raw['keyString'] ??
|
||||
raw['customerKey'] ??
|
||||
raw['key'];
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function normalizeFieldValue(value: string | null | undefined): string {
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function isPasswordParam(id: string): boolean {
|
||||
return id.toLowerCase().includes('secret');
|
||||
}
|
||||
|
||||
function isP8Param(id: string): boolean {
|
||||
return id === 'p8File' || id === 'p8' || id.toLowerCase().includes('p8file');
|
||||
}
|
||||
|
||||
function isSecretParam(id: string): boolean {
|
||||
return isPasswordParam(id) || isP8Param(id);
|
||||
}
|
||||
|
||||
function isOidcAdvancedParam(id: string): boolean {
|
||||
return id === 'authorizationURL' || id === 'tokenUrl' || id === 'userInfoUrl';
|
||||
}
|
||||
|
||||
async function handleP8FileUpload(id: string, event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
fieldValues[id] = await file.text();
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function buildSecret(): string | null {
|
||||
const entries = secretParams.flatMap((param) => {
|
||||
const value = fieldValues[param.$id];
|
||||
|
||||
if (value === null || value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[param.$id, value] as [string, string]];
|
||||
});
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return JSON.stringify(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function buildDetails(): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
detailParams.map((param) => [param.$id, fieldValues[param.$id] ?? ''])
|
||||
);
|
||||
}
|
||||
|
||||
function resetWriteOnlyInputs() {
|
||||
showSecretInput = false;
|
||||
|
||||
for (const param of secretParams) {
|
||||
fieldValues[param.$id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
const result = await updateOAuth({
|
||||
region,
|
||||
projectId,
|
||||
provider,
|
||||
appId,
|
||||
secret: buildSecret(),
|
||||
details: buildDetails(),
|
||||
enabled
|
||||
});
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
@@ -37,6 +206,8 @@
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
// @todo Revisit explicit secret-clearing UX later today. For now, credentials remain update-only.
|
||||
</script>
|
||||
|
||||
<Modal {error} bind:show onSubmit={update} on:close title={`${provider.name} OAuth2 settings`}>
|
||||
@@ -49,21 +220,157 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">visit the docs.</Link.Anchor>
|
||||
</p>
|
||||
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="appID"
|
||||
label="App ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="App Secret"
|
||||
placeholder="Enter App Secret"
|
||||
minlength={0}
|
||||
bind:value={secret}
|
||||
required />
|
||||
|
||||
{#if appIdParam}
|
||||
<InputText
|
||||
id="appID"
|
||||
label={primaryName(appIdParam.name)}
|
||||
autofocus={true}
|
||||
placeholder={appIdParam.example || ''}
|
||||
helper={helperText(appIdParam.hint)}
|
||||
required={enabled && !!appIdParam.example}
|
||||
bind:value={appId} />
|
||||
{/if}
|
||||
|
||||
{#each basicDetailParams as param}
|
||||
<InputText
|
||||
id={param.$id}
|
||||
label={primaryName(param.name)}
|
||||
placeholder={param.example || ''}
|
||||
helper={helperText(param.hint)}
|
||||
required={enabled && !!param.example}
|
||||
bind:value={fieldValues[param.$id]} />
|
||||
{/each}
|
||||
|
||||
{#if advancedDetailParams.length > 0}
|
||||
<Accordion title="Advanced" badge="Optional" hideDivider>
|
||||
<Layout.Stack gap="l">
|
||||
{#each advancedDetailParams as param}
|
||||
<InputText
|
||||
id={param.$id}
|
||||
label={primaryName(param.name)}
|
||||
placeholder={param.example || ''}
|
||||
helper={helperText(param.hint)}
|
||||
bind:value={fieldValues[param.$id]} />
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</Accordion>
|
||||
{/if}
|
||||
|
||||
{#if secretParams.length > 0}
|
||||
{#if !showSecretInput}
|
||||
<div>
|
||||
<Tag size="s" on:click={() => (showSecretInput = true)}>
|
||||
<Icon icon={IconPencil} size="s" /> Update {secretCardTitle}
|
||||
</Tag>
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Base
|
||||
variant="secondary"
|
||||
padding="s"
|
||||
--input-background-color="var(--bgcolor-neutral-primary)">
|
||||
<Layout.Stack gap="xl">
|
||||
<Layout.Stack gap="s">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignContent="center">
|
||||
<Typography.Text variant="m-600">{secretCardTitle}</Typography.Text>
|
||||
<Button extraCompact on:click={resetWriteOnlyInputs}>
|
||||
<Icon icon={IconX} size="s" />
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
<Typography.Text>
|
||||
{#if secretParams.length === 1}
|
||||
This field is write-only. Enter a new value to update it.
|
||||
{:else}
|
||||
These fields are write-only. Enter new values to update them.
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<span
|
||||
style="margin-left: calc(-1 * var(--space-7)); margin-right: calc(-1 * var(--space-7)); width: auto;">
|
||||
<Divider />
|
||||
</span>
|
||||
<Layout.Stack>
|
||||
{#each secretParams as param}
|
||||
{#if isP8Param(param.$id)}
|
||||
<div class="p8-field">
|
||||
<div class="p8-field-header">
|
||||
<Typography.Text variant="m-400"
|
||||
>{primaryName(param.name)}</Typography.Text>
|
||||
<div class="p8-field-controls">
|
||||
<Button
|
||||
text
|
||||
extraCompact
|
||||
on:click={() =>
|
||||
(p8PasteMode[param.$id] =
|
||||
!p8PasteMode[param.$id])}>
|
||||
{p8PasteMode[param.$id]
|
||||
? 'Upload file instead'
|
||||
: 'Paste instead'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if p8PasteMode[param.$id]}
|
||||
<InputTextarea
|
||||
id={param.$id}
|
||||
label=""
|
||||
placeholder={'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'}
|
||||
helper={helperText(param.hint)}
|
||||
bind:value={fieldValues[param.$id]} />
|
||||
{:else}
|
||||
<label
|
||||
class="p8-upload-zone"
|
||||
class:has-file={!!fieldValues[param.$id]}
|
||||
for="p8-file-{param.$id}">
|
||||
{#if fieldValues[param.$id]}
|
||||
<Icon icon={IconDocument} size="s" />
|
||||
<Typography.Text
|
||||
>File loaded — click to replace</Typography.Text>
|
||||
<Button
|
||||
text
|
||||
extraCompact
|
||||
on:click={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fieldValues[param.$id] = null;
|
||||
}}>
|
||||
<Icon icon={IconX} size="s" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Icon icon={IconUpload} size="m" />
|
||||
<Typography.Text
|
||||
>Click to upload .p8 file</Typography.Text>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
id="p8-file-{param.$id}"
|
||||
type="file"
|
||||
accept=".p8"
|
||||
style="display:none"
|
||||
on:change={(event) =>
|
||||
handleP8FileUpload(param.$id, event)} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<InputPassword
|
||||
id={param.$id}
|
||||
label={primaryName(param.name)}
|
||||
placeholder={param.example || ''}
|
||||
helper={helperText(param.hint)}
|
||||
minlength={0}
|
||||
bind:value={fieldValues[param.$id]} />
|
||||
{/if}
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
@@ -72,12 +379,52 @@
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!secret ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
<Button disabled={nothingChanged} submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.p8-upload-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border: 1px dashed var(--border-neutral-secondary);
|
||||
border-radius: var(--border-radius-small);
|
||||
cursor: pointer;
|
||||
background: var(--bgcolor-neutral-secondary);
|
||||
transition: border-color 0.15s;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.p8-upload-zone:hover {
|
||||
border-color: var(--border-neutral-primary);
|
||||
}
|
||||
|
||||
.p8-upload-zone.has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--border-neutral-primary);
|
||||
}
|
||||
|
||||
.p8-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.p8-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.p8-field-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import { Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const projectId = page.params.project;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let appId: string = null;
|
||||
let enabled: boolean = null;
|
||||
let clientSecret: string = null;
|
||||
let tenantID: string = null;
|
||||
let error: string;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
appId ??= provider.appId;
|
||||
enabled ??= provider.enabled;
|
||||
if (provider.secret) ({ clientSecret, tenantID } = JSON.parse(provider.secret));
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: secret = clientSecret
|
||||
? JSON.stringify({ clientSecret, ...(tenantID && { tenantID }) })
|
||||
: provider.secret;
|
||||
</script>
|
||||
|
||||
<Modal {error} onSubmit={update} bind:show on:close title={`${provider.name} OAuth2 settings`}>
|
||||
<p slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer">
|
||||
visit the docs.
|
||||
</a>
|
||||
</p>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="appID"
|
||||
label="Application (client) ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="Client Secret"
|
||||
placeholder="Enter Client Secret"
|
||||
minlength={0}
|
||||
bind:value={clientSecret}
|
||||
required />
|
||||
<InputText
|
||||
id="tenant"
|
||||
label="Target Tenant"
|
||||
placeholder="'common','organizations','consumers' or your TenantID"
|
||||
bind:value={tenantID} />
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!clientSecret ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import { Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const projectId = page.params.project;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let appId: string = null;
|
||||
let enabled: boolean = null;
|
||||
let clientSecret: string = null;
|
||||
let wellKnownEndpoint: string = null;
|
||||
let authorizationEndpoint: string = null;
|
||||
let tokenEndpoint: string = null;
|
||||
let userinfoEndpoint: string = null;
|
||||
let error: string;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
// secret is valid if clientSecret is set and either wellKnownEndpoint or all of the other endpoints are set
|
||||
$: isValidSecret =
|
||||
clientSecret &&
|
||||
(wellKnownEndpoint || (authorizationEndpoint && tokenEndpoint && userinfoEndpoint));
|
||||
|
||||
onMount(() => {
|
||||
appId ??= provider.appId;
|
||||
enabled ??= provider.enabled;
|
||||
if (provider.secret) {
|
||||
({
|
||||
clientSecret,
|
||||
wellKnownEndpoint,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
userinfoEndpoint
|
||||
} = JSON.parse(provider.secret));
|
||||
}
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: secret = isValidSecret
|
||||
? JSON.stringify({
|
||||
clientSecret,
|
||||
wellKnownEndpoint,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
userinfoEndpoint
|
||||
})
|
||||
: provider.secret;
|
||||
</script>
|
||||
|
||||
<Modal {error} onSubmit={update} title="{provider.name} OAuth2 settings" bind:show on:close>
|
||||
<p slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
|
||||
>visit the docs.</a>
|
||||
</p>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="appID"
|
||||
label="Client ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="Client Secret"
|
||||
placeholder="Enter Client Secret"
|
||||
minlength={0}
|
||||
bind:value={clientSecret}
|
||||
required />
|
||||
<InputText
|
||||
id="well-known-endpoint"
|
||||
label="Well-Known Endpoint"
|
||||
placeholder="https://example.com/.well-known/openid-configuration"
|
||||
bind:value={wellKnownEndpoint}
|
||||
required={!authorizationEndpoint && !tokenEndpoint && !userinfoEndpoint} />
|
||||
<InputText
|
||||
id="authorization-endpoint"
|
||||
label="Authorization Endpoint"
|
||||
placeholder="https://example.com/authorize"
|
||||
bind:value={authorizationEndpoint}
|
||||
required={!wellKnownEndpoint} />
|
||||
<InputText
|
||||
id="token-endpoint"
|
||||
label="Token Endpoint"
|
||||
placeholder="https://example.com/token"
|
||||
bind:value={tokenEndpoint}
|
||||
required={!wellKnownEndpoint} />
|
||||
<InputText
|
||||
id="userinfo-endpoint"
|
||||
label="User Info Endpoint"
|
||||
placeholder="https://example.com/userinfo"
|
||||
bind:value={userinfoEndpoint}
|
||||
required={!wellKnownEndpoint} />
|
||||
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!clientSecret ||
|
||||
(!wellKnownEndpoint &&
|
||||
(!authorizationEndpoint || !tokenEndpoint || !userinfoEndpoint)) ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { CopyInput, Modal } from '$lib/components';
|
||||
import { Button, InputPassword, InputSwitch, InputText } from '$lib/elements/forms';
|
||||
import { oAuthProviders, type Provider } from '$lib/stores/oauth-providers';
|
||||
import { getApiEndpoint } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { updateOAuth } from '../updateOAuth';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const projectId = page.params.project;
|
||||
|
||||
export let provider: Models.AuthProvider;
|
||||
export let show = false;
|
||||
|
||||
let appId: string = null;
|
||||
let enabled: boolean = null;
|
||||
let clientSecret: string = null;
|
||||
let oktaDomain: string = null;
|
||||
let authorizationServerId: string = null;
|
||||
let error: string;
|
||||
let oAuthProvider: Provider;
|
||||
|
||||
onMount(() => {
|
||||
appId ??= provider.appId;
|
||||
enabled ??= provider.enabled;
|
||||
if (provider.secret) {
|
||||
({ clientSecret, oktaDomain, authorizationServerId } = JSON.parse(provider.secret));
|
||||
}
|
||||
oAuthProvider = oAuthProviders[provider.key];
|
||||
});
|
||||
|
||||
const update = async () => {
|
||||
const result = await updateOAuth({ projectId, provider, secret, appId, enabled });
|
||||
|
||||
if (result.status === 'error') {
|
||||
error = result.message;
|
||||
} else {
|
||||
provider = null;
|
||||
}
|
||||
};
|
||||
|
||||
$: secret =
|
||||
clientSecret && oktaDomain && authorizationServerId
|
||||
? JSON.stringify({ clientSecret, oktaDomain, authorizationServerId })
|
||||
: provider.secret;
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{error}
|
||||
onSubmit={update}
|
||||
size="m"
|
||||
bind:show
|
||||
on:close
|
||||
title={`${provider.name} OAuth2 settings`}>
|
||||
<p slot="description">
|
||||
To use {provider.name} authentication in your application, first fill in this form. For more info
|
||||
you can
|
||||
<a class="link" href={oAuthProvider?.docs} target="_blank" rel="noopener noreferrer"
|
||||
>visit the docs.</a>
|
||||
</p>
|
||||
<InputSwitch id="state" bind:value={enabled} label={enabled ? 'Enabled' : 'Disabled'} />
|
||||
<InputText
|
||||
id="appID"
|
||||
label="Client ID"
|
||||
autofocus={true}
|
||||
placeholder="Enter ID"
|
||||
bind:value={appId}
|
||||
required />
|
||||
<InputPassword
|
||||
id="secret"
|
||||
label="Client Secret"
|
||||
placeholder="Enter Client Secret"
|
||||
minlength={0}
|
||||
bind:value={clientSecret}
|
||||
required />
|
||||
<InputText
|
||||
id="domain"
|
||||
label="Okta Domain"
|
||||
placeholder="dev-1337.okta.com"
|
||||
bind:value={oktaDomain}
|
||||
required />
|
||||
<InputText
|
||||
id="serverID"
|
||||
label="Authorization Server ID"
|
||||
placeholder="default"
|
||||
bind:value={authorizationServerId}
|
||||
required />
|
||||
|
||||
<Alert.Inline status="info">
|
||||
To complete set up, add this OAuth2 redirect URI to your {provider.name} app configuration.
|
||||
</Alert.Inline>
|
||||
<CopyInput
|
||||
label="URI"
|
||||
value={`${getApiEndpoint(page.params.region)}/account/sessions/oauth2/callback/${provider.key}/${projectId}`} />
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (provider = null)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={!appId ||
|
||||
!clientSecret ||
|
||||
!oktaDomain ||
|
||||
!authorizationServerId ||
|
||||
(secret === provider.secret &&
|
||||
enabled === provider.enabled &&
|
||||
appId === provider.appId)}
|
||||
submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -14,9 +14,7 @@
|
||||
let { children } = $props();
|
||||
|
||||
const authProjectRoute = $derived.by(() => {
|
||||
return resolveRoute('/(console)/project-[region]-[project]/auth', {
|
||||
...page.params
|
||||
});
|
||||
return resolveRoute('/(console)/project-[region]-[project]/auth', page.params);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
+5
-8
@@ -56,20 +56,17 @@
|
||||
|
||||
async function updatePasswordPolicies() {
|
||||
try {
|
||||
const projectSdk = sdk.forConsole.projects;
|
||||
const projectSdk = sdk.forProject(project.region, project.$id).project;
|
||||
|
||||
await projectSdk.updateAuthPasswordHistory({
|
||||
projectId: project.$id,
|
||||
limit: passwordHistoryEnabled ? passwordHistory : 0
|
||||
await projectSdk.updatePasswordHistoryPolicy({
|
||||
total: passwordHistoryEnabled ? passwordHistory : 0
|
||||
});
|
||||
|
||||
await projectSdk.updateAuthPasswordDictionary({
|
||||
projectId: project.$id,
|
||||
await projectSdk.updatePasswordDictionaryPolicy({
|
||||
enabled: passwordDictionary
|
||||
});
|
||||
|
||||
await projectSdk.updatePersonalDataCheck({
|
||||
projectId: project.$id,
|
||||
await projectSdk.updatePasswordPersonalDataPolicy({
|
||||
enabled: authPersonalDataCheck
|
||||
});
|
||||
|
||||
|
||||
+4
-5
@@ -29,12 +29,11 @@
|
||||
|
||||
async function updateSessionSecurity() {
|
||||
try {
|
||||
await sdk.forConsole.projects.updateSessionAlerts({
|
||||
projectId: project.$id,
|
||||
alerts: authSessionAlerts
|
||||
const projectSdk = sdk.forProject(project.region, project.$id).project;
|
||||
await projectSdk.updateSessionAlertPolicy({
|
||||
enabled: authSessionAlerts
|
||||
});
|
||||
await sdk.forConsole.projects.updateSessionInvalidation({
|
||||
projectId: project.$id,
|
||||
await projectSdk.updateSessionInvalidationPolicy({
|
||||
enabled: sessionInvalidation
|
||||
});
|
||||
|
||||
|
||||
+30
-11
@@ -16,17 +16,30 @@
|
||||
} = $props();
|
||||
|
||||
let authMembershipsMfa = $state(project?.authMembershipsMfa ?? true);
|
||||
let authMembershipsUserId = $state(project?.authMembershipsUserId ?? true);
|
||||
let authMembershipsUserName = $state(project?.authMembershipsUserName ?? true);
|
||||
let authMembershipsUserEmail = $state(project?.authMembershipsUserEmail ?? true);
|
||||
let authMembershipsUserPhone = $state(project?.authMembershipsUserPhone ?? true);
|
||||
|
||||
const isSubmitDisabled = $derived(
|
||||
authMembershipsUserId === (project?.authMembershipsUserId ?? true) &&
|
||||
authMembershipsUserName === (project?.authMembershipsUserName ?? true) &&
|
||||
authMembershipsUserEmail === (project?.authMembershipsUserEmail ?? true) &&
|
||||
authMembershipsUserPhone === (project?.authMembershipsUserPhone ?? true) &&
|
||||
authMembershipsMfa === (project?.authMembershipsMfa ?? true)
|
||||
);
|
||||
|
||||
async function updateMembershipsPrivacy() {
|
||||
try {
|
||||
await sdk.forConsole.projects.updateMembershipsPrivacy({
|
||||
projectId: project.$id,
|
||||
userName: authMembershipsUserName,
|
||||
userEmail: authMembershipsUserEmail,
|
||||
mfa: authMembershipsMfa
|
||||
});
|
||||
await sdk
|
||||
.forProject(project.region, project.$id)
|
||||
.project.updateMembershipPrivacyPolicy({
|
||||
userId: authMembershipsUserId,
|
||||
userName: authMembershipsUserName,
|
||||
userEmail: authMembershipsUserEmail,
|
||||
userPhone: authMembershipsUserPhone,
|
||||
userMFA: authMembershipsMfa
|
||||
});
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -48,6 +61,11 @@
|
||||
<svelte:fragment slot="title">Memberships privacy</svelte:fragment>
|
||||
Set privacy preferences to manage which details team members can view about one another.
|
||||
<svelte:fragment slot="aside">
|
||||
<Selector.Checkbox
|
||||
id="membershipsUserId"
|
||||
label="User ID"
|
||||
description="Display team members' user IDs to other team members"
|
||||
bind:checked={authMembershipsUserId} />
|
||||
<Selector.Checkbox
|
||||
id="membershipsUserName"
|
||||
label="Name"
|
||||
@@ -58,6 +76,11 @@
|
||||
label="Email"
|
||||
description="Allow team members to view each other's email addresses"
|
||||
bind:checked={authMembershipsUserEmail} />
|
||||
<Selector.Checkbox
|
||||
id="membershipsUserPhone"
|
||||
label="Phone"
|
||||
description="Allow team members to view each other's phone numbers"
|
||||
bind:checked={authMembershipsUserPhone} />
|
||||
<Selector.Checkbox
|
||||
id="membershipsMfa"
|
||||
label="MFA status"
|
||||
@@ -65,11 +88,7 @@
|
||||
bind:checked={authMembershipsMfa} />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
disabled={authMembershipsUserName === (project?.authMembershipsUserName ?? true) &&
|
||||
authMembershipsUserEmail === (project?.authMembershipsUserEmail ?? true) &&
|
||||
authMembershipsMfa === (project?.authMembershipsMfa ?? true)}
|
||||
submit>Update</Button>
|
||||
<Button disabled={isSubmitDisabled} submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
</Form>
|
||||
|
||||
+375
-138
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { CardGrid } from '$lib/components';
|
||||
import { InputPhone, InputOTP, Button, Form } from '$lib/elements/forms';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import { InputPhone, InputOTP, Button } from '$lib/elements/forms';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { getChangePlanUrl } from '$lib/stores/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
import { isCloud, isSelfHosted } from '$lib/system';
|
||||
import MockNumbersLight from './mock-numbers-light.png';
|
||||
@@ -15,8 +14,8 @@
|
||||
import { app } from '$lib/stores/app';
|
||||
import Empty from '$lib/components/empty.svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Icon, Input, Layout, Link, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { IconPlus, IconRefresh } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Icon, Input, Layout, Link, Spinner, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { IconPlus, IconRefresh, IconTrash } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
const {
|
||||
project
|
||||
@@ -24,12 +23,21 @@
|
||||
project: Models.Project;
|
||||
} = $props();
|
||||
|
||||
let numbers: Models.MockNumber[] = $state(project?.authMockNumbers ?? []);
|
||||
let lastProjectId: string = $state(project?.$id ?? null);
|
||||
type MockNumberForm = Pick<Models.MockNumber, 'number' | 'otp'> & {
|
||||
draftId?: string;
|
||||
initialNumber?: string;
|
||||
initialOtp?: string;
|
||||
};
|
||||
|
||||
const initialNumbers = $derived(project.authMockNumbers?.map((n) => ({ ...n })) ?? []);
|
||||
|
||||
const isSubmitDisabled = $derived(JSON.stringify(numbers) === JSON.stringify(initialNumbers));
|
||||
let savedNumbers: MockNumberForm[] = $state([]);
|
||||
let draftNumbers: MockNumberForm[] = $state([]);
|
||||
let isLoadingMockNumbers = $state(false);
|
||||
let lastProjectId: string | null = $state(null);
|
||||
let pendingRow: string | null = $state(null);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let deleteTargetIndex: number | null = $state(null);
|
||||
let deleteError: string | null = $state(null);
|
||||
let draftIdCounter = $state(0);
|
||||
|
||||
const isComponentDisabled = $derived(
|
||||
isSelfHosted || (isCloud && !$currentPlan.supportsMockNumbers)
|
||||
@@ -51,33 +59,149 @@
|
||||
const id = project?.$id ?? null;
|
||||
if (id && id !== lastProjectId) {
|
||||
lastProjectId = id;
|
||||
numbers = project?.authMockNumbers ?? [];
|
||||
void loadMockNumbers();
|
||||
}
|
||||
});
|
||||
|
||||
async function updateMockNumbers() {
|
||||
function getMockNumberRows(mockNumbers: Models.MockNumber[]): MockNumberForm[] {
|
||||
return mockNumbers.map(({ number, otp }) => ({
|
||||
number,
|
||||
otp,
|
||||
initialNumber: number,
|
||||
initialOtp: otp
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadMockNumbers() {
|
||||
try {
|
||||
await sdk.forConsole.projects.updateMockNumbers({
|
||||
projectId: project.$id,
|
||||
numbers
|
||||
if (savedNumbers.length === 0) {
|
||||
isLoadingMockNumbers = true;
|
||||
}
|
||||
const response = await sdk
|
||||
.forProject(project.region, project.$id)
|
||||
.project.listMockPhones({});
|
||||
savedNumbers = getMockNumberRows(response.mockNumbers ?? []);
|
||||
} catch (error) {
|
||||
addNotification({ type: 'error', message: error.message });
|
||||
trackError(error, Submit.AuthMockNumbersUpdate);
|
||||
} finally {
|
||||
isLoadingMockNumbers = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSavedRowKey(number: MockNumberForm, index: number) {
|
||||
return number.initialNumber ?? number.number ?? `saved-${index}`;
|
||||
}
|
||||
|
||||
function createDraftNumber(): MockNumberForm {
|
||||
draftIdCounter += 1;
|
||||
return {
|
||||
draftId: `draft-${draftIdCounter}`,
|
||||
number: generateNumber(),
|
||||
otp: generateOTP()
|
||||
};
|
||||
}
|
||||
|
||||
function getDraftRowKey(number: MockNumberForm, index: number) {
|
||||
return number.draftId ?? `draft-${index}`;
|
||||
}
|
||||
|
||||
function isSaved(number: MockNumberForm) {
|
||||
return Boolean(number.initialNumber);
|
||||
}
|
||||
|
||||
function isValid(number: MockNumberForm) {
|
||||
return (
|
||||
number.number?.length >= 9 &&
|
||||
number.number?.length <= 16 &&
|
||||
/^[0-9]{6}$/.test(number.otp ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
function isChanged(number: MockNumberForm) {
|
||||
return (
|
||||
!isSaved(number) ||
|
||||
number.number !== number.initialNumber ||
|
||||
number.otp !== number.initialOtp
|
||||
);
|
||||
}
|
||||
|
||||
function setDraftRow(index: number, nextValue: MockNumberForm) {
|
||||
draftNumbers = draftNumbers.map((row, rowIndex) => (rowIndex === index ? nextValue : row));
|
||||
}
|
||||
|
||||
async function createPhoneNumber(number: MockNumberForm, index: number) {
|
||||
const rowKey = getDraftRowKey(number, index);
|
||||
try {
|
||||
const projectSdk = sdk.forProject(project.region, project.$id).project;
|
||||
pendingRow = rowKey;
|
||||
await projectSdk.createMockPhone({
|
||||
number: number.number,
|
||||
otp: number.otp
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
|
||||
pendingRow = null;
|
||||
draftNumbers = draftNumbers.filter((_, rowIndex) => rowIndex !== index);
|
||||
await loadMockNumbers();
|
||||
addNotification({ type: 'success', message: 'Mock phone numbers have been updated' });
|
||||
trackEvent(Submit.AuthMockNumbersUpdate);
|
||||
} catch (error) {
|
||||
addNotification({ type: 'error', message: error.message });
|
||||
trackError(error, Submit.AuthMockNumbersUpdate);
|
||||
} finally {
|
||||
pendingRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
function addPhoneNumber(number: Models.MockNumber) {
|
||||
numbers = [...numbers, { phone: number.phone, otp: number.otp }];
|
||||
async function updatePhoneNumber(number: MockNumberForm, index: number) {
|
||||
if (!number.initialNumber) return;
|
||||
|
||||
const rowKey = getSavedRowKey(number, index);
|
||||
try {
|
||||
const projectSdk = sdk.forProject(project.region, project.$id).project;
|
||||
pendingRow = rowKey;
|
||||
await projectSdk.updateMockPhone({
|
||||
number: number.initialNumber,
|
||||
otp: number.otp
|
||||
});
|
||||
|
||||
await loadMockNumbers();
|
||||
addNotification({ type: 'success', message: 'Mock phone numbers have been updated' });
|
||||
trackEvent(Submit.AuthMockNumbersUpdate);
|
||||
} catch (error) {
|
||||
addNotification({ type: 'error', message: error.message });
|
||||
trackError(error, Submit.AuthMockNumbersUpdate);
|
||||
} finally {
|
||||
pendingRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePhoneNumber(index: number) {
|
||||
numbers = numbers.filter((_, i) => i !== index);
|
||||
async function deletePhoneNumber(number: MockNumberForm, index: number) {
|
||||
if (!number.initialNumber) {
|
||||
draftNumbers = draftNumbers.filter((_, rowIndex) => rowIndex !== index);
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKey = getSavedRowKey(number, index);
|
||||
try {
|
||||
const projectSdk = sdk.forProject(project.region, project.$id).project;
|
||||
pendingRow = rowKey;
|
||||
await projectSdk.deleteMockPhone({ number: number.initialNumber });
|
||||
|
||||
savedNumbers = savedNumbers.filter((row) => row.initialNumber !== number.initialNumber);
|
||||
await loadMockNumbers();
|
||||
addNotification({ type: 'success', message: 'Mock phone number has been deleted' });
|
||||
trackEvent(Submit.AuthMockNumbersUpdate);
|
||||
} catch (error) {
|
||||
deleteError = error.message;
|
||||
trackError(error, Submit.AuthMockNumbersUpdate);
|
||||
} finally {
|
||||
pendingRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
function addPhoneNumber() {
|
||||
draftNumbers = [...draftNumbers, createDraftNumber()];
|
||||
}
|
||||
|
||||
function generateNumber(): string {
|
||||
@@ -91,148 +215,261 @@
|
||||
function generateOTP(): string {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
function getDeleteTargetRow() {
|
||||
return deleteTargetIndex === null ? null : (savedNumbers[deleteTargetIndex] ?? null);
|
||||
}
|
||||
|
||||
function isDeletePending() {
|
||||
const row = getDeleteTargetRow();
|
||||
return (
|
||||
row !== null &&
|
||||
deleteTargetIndex !== null &&
|
||||
pendingRow === getSavedRowKey(row, deleteTargetIndex)
|
||||
);
|
||||
}
|
||||
|
||||
function promptDelete(index: number) {
|
||||
deleteTargetIndex = index;
|
||||
deleteError = null;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
async function confirmDeletePhoneNumber() {
|
||||
const row = getDeleteTargetRow();
|
||||
if (row === null || deleteTargetIndex === null) return;
|
||||
|
||||
deleteError = null;
|
||||
await deletePhoneNumber(row, deleteTargetIndex);
|
||||
|
||||
if (!deleteError) {
|
||||
showDeleteConfirm = false;
|
||||
deleteTargetIndex = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Form onSubmit={updateMockNumbers}>
|
||||
<CardGrid hideFooter={isComponentDisabled}>
|
||||
<svelte:fragment slot="title">Mock phone numbers</svelte:fragment>
|
||||
Generate <b>fictional</b> numbers to simulate phone verification when testing demo accounts
|
||||
for submitting your application to the App Store or Google Play.
|
||||
<Link.Anchor
|
||||
href="https://appwrite.io/docs/products/auth/security#mock-phone-numbers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Learn more</Link.Anchor>
|
||||
<svelte:fragment slot="aside">
|
||||
{#if isComponentDisabled}
|
||||
<EmptyCardImageCloud responsive source="email_signature_card">
|
||||
<svelte:fragment slot="image">
|
||||
<div class=" is-only-mobile u-width-full-line u-height-100-percent">
|
||||
<CardGrid hideFooter>
|
||||
<svelte:fragment slot="title">Mock phone numbers</svelte:fragment>
|
||||
Generate <b>fictional</b> numbers to simulate phone verification when testing demo accounts for
|
||||
submitting your application to the App Store or Google Play.
|
||||
<Link.Anchor
|
||||
href="https://appwrite.io/docs/products/auth/security#mock-phone-numbers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Learn more</Link.Anchor>
|
||||
<svelte:fragment slot="aside">
|
||||
{#if isComponentDisabled}
|
||||
<EmptyCardImageCloud responsive source="email_signature_card">
|
||||
<svelte:fragment slot="image">
|
||||
<div class=" is-only-mobile u-width-full-line u-height-100-percent">
|
||||
{#if $app.themeInUse === 'dark'}
|
||||
<img
|
||||
src={MockNumbersDark}
|
||||
class="u-image-object-fit-cover u-only-dark u-width-full-line u-height-100-percent"
|
||||
alt="Mock Numbers Example" />
|
||||
{:else}
|
||||
<img
|
||||
src={MockNumbersLight}
|
||||
class="u-image-object-fit-cover u-only-light u-width-full-line u-height-100-percent"
|
||||
alt="Mock Numbers Example" />
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="is-not-mobile"
|
||||
style:background-color="var(--bgcolor-neutral-default)">
|
||||
<Layout.Stack justifyContent="center" direction="row">
|
||||
{#if $app.themeInUse === 'dark'}
|
||||
<img
|
||||
src={MockNumbersDark}
|
||||
class="u-image-object-fit-cover u-only-dark u-width-full-line u-height-100-percent"
|
||||
width="266"
|
||||
style:object-position="top"
|
||||
alt="Mock Numbers Example" />
|
||||
{:else}
|
||||
<img
|
||||
src={MockNumbersLight}
|
||||
class="u-image-object-fit-cover u-only-light u-width-full-line u-height-100-percent"
|
||||
width="266"
|
||||
style:object-position="top"
|
||||
alt="Mock Numbers Example" />
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="is-not-mobile"
|
||||
style:background-color="var(--bgcolor-neutral-default)">
|
||||
<Layout.Stack justifyContent="center" direction="row">
|
||||
{#if $app.themeInUse === 'dark'}
|
||||
<img
|
||||
src={MockNumbersDark}
|
||||
width="266"
|
||||
style:object-position="top"
|
||||
alt="Mock Numbers Example" />
|
||||
{:else}
|
||||
<img
|
||||
src={MockNumbersLight}
|
||||
width="266"
|
||||
style:object-position="top"
|
||||
alt="Mock Numbers Example" />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">{emptyStateTitle}</svelte:fragment>
|
||||
{emptyStateDescription}
|
||||
<svelte:fragment let:source slot="cta">
|
||||
<Button
|
||||
secondary
|
||||
fullWidth
|
||||
external={isSelfHosted}
|
||||
href={isCloud
|
||||
? getChangePlanUrl(project.teamId)
|
||||
: 'https://cloud.appwrite.io/register'}
|
||||
on:click={() => {
|
||||
trackEvent(Click.CloudSignupClick, {
|
||||
from: 'button',
|
||||
source
|
||||
});
|
||||
}}>{cta}</Button>
|
||||
</svelte:fragment>
|
||||
</EmptyCardImageCloud>
|
||||
{:else if numbers?.length > 0}
|
||||
{#each numbers as number, index}
|
||||
<Layout.Stack direction="row" alignItems="flex-end">
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">{emptyStateTitle}</svelte:fragment>
|
||||
{emptyStateDescription}
|
||||
<svelte:fragment let:source slot="cta">
|
||||
<Button
|
||||
secondary
|
||||
fullWidth
|
||||
external={isSelfHosted}
|
||||
href={isCloud
|
||||
? getChangePlanUrl(project.teamId)
|
||||
: 'https://cloud.appwrite.io/register'}
|
||||
on:click={() => {
|
||||
trackEvent(Click.CloudSignupClick, {
|
||||
from: 'button',
|
||||
source
|
||||
});
|
||||
}}>{cta}</Button>
|
||||
</svelte:fragment>
|
||||
</EmptyCardImageCloud>
|
||||
{:else if isLoadingMockNumbers}
|
||||
<Layout.Stack direction="row" justifyContent="center">
|
||||
<Spinner />
|
||||
</Layout.Stack>
|
||||
{:else if savedNumbers.length > 0 || draftNumbers.length > 0}
|
||||
{#each savedNumbers as number, index (getSavedRowKey(number, index))}
|
||||
<Layout.Stack direction="row" alignItems="flex-end">
|
||||
<Layout.Stack direction="row" alignItems="flex-end" gap="xs">
|
||||
<InputPhone
|
||||
id={`key-${index}`}
|
||||
bind:value={number.phone}
|
||||
id={`saved-key-${index}`}
|
||||
bind:value={number.number}
|
||||
placeholder="Enter phone number"
|
||||
label={index === 0 ? 'Phone number' : undefined}
|
||||
minlength={9}
|
||||
maxlength={16}
|
||||
disabled={isSaved(number)}
|
||||
required />
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row" alignItems="flex-end" gap="xs">
|
||||
<InputOTP
|
||||
id={`saved-value-${index}`}
|
||||
bind:value={number.otp}
|
||||
placeholder="Enter value"
|
||||
label={index === 0 ? 'Verification code' : undefined}
|
||||
maxlength={6}
|
||||
pattern={'^[0-9]{6}$'}
|
||||
patternError="The value must contain 6 digits"
|
||||
required>
|
||||
<Tooltip slot="end">
|
||||
<Input.Action
|
||||
icon={IconRefresh}
|
||||
on:click={() => (number.otp = generateOTP())} />
|
||||
<span slot="tooltip">Regenerate</span>
|
||||
</Tooltip>
|
||||
</InputOTP>
|
||||
{#if isSaved(number)}
|
||||
<Button
|
||||
compact
|
||||
disabled={!isChanged(number) ||
|
||||
!isValid(number) ||
|
||||
pendingRow === getSavedRowKey(number, index)}
|
||||
on:click={() => updatePhoneNumber(number, index)}>
|
||||
Save
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
icon
|
||||
text
|
||||
disabled={pendingRow === getSavedRowKey(number, index)}
|
||||
ariaLabel="Delete mock phone number"
|
||||
on:click={() => promptDelete(index)}>
|
||||
<Icon icon={IconTrash} size="s" />
|
||||
</Button>
|
||||
{#if pendingRow === getSavedRowKey(number, index)}
|
||||
<span style:opacity="0.75">
|
||||
<Spinner size="s" />
|
||||
</span>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{/each}
|
||||
{#each draftNumbers as number, index (getDraftRowKey(number, index))}
|
||||
<Layout.Stack direction="row" alignItems="flex-end">
|
||||
<Layout.Stack direction="row" alignItems="flex-end" gap="xs">
|
||||
<InputPhone
|
||||
id={`draft-key-${index}`}
|
||||
bind:value={number.number}
|
||||
placeholder="Enter phone number"
|
||||
label={savedNumbers.length === 0 && index === 0
|
||||
? 'Phone number'
|
||||
: undefined}
|
||||
minlength={9}
|
||||
maxlength={16}
|
||||
required>
|
||||
<Tooltip slot="end">
|
||||
<Input.Action
|
||||
icon={IconRefresh}
|
||||
on:click={() => (number.phone = generateNumber())} />
|
||||
on:click={() =>
|
||||
setDraftRow(index, {
|
||||
...number,
|
||||
number: generateNumber()
|
||||
})} />
|
||||
<span slot="tooltip">Regenerate</span>
|
||||
</Tooltip>
|
||||
</InputPhone>
|
||||
<Layout.Stack direction="row" alignItems="flex-end" gap="xs">
|
||||
<InputOTP
|
||||
id={`value-${index}`}
|
||||
bind:value={number.otp}
|
||||
placeholder="Enter value"
|
||||
label={index === 0 ? 'Verification code' : undefined}
|
||||
maxlength={6}
|
||||
pattern={'^[0-9]{6}$'}
|
||||
patternError="The value must contain 6 digits"
|
||||
required>
|
||||
<Tooltip slot="end">
|
||||
<Input.Action
|
||||
icon={IconRefresh}
|
||||
on:click={() => (number.otp = generateOTP())} />
|
||||
<span slot="tooltip">Regenerate</span>
|
||||
</Tooltip>
|
||||
</InputOTP>
|
||||
<Button
|
||||
icon
|
||||
compact
|
||||
disabled={numbers.length === 0}
|
||||
on:click={() => {
|
||||
deletePhoneNumber(index);
|
||||
}}>
|
||||
<span class="icon-x" aria-hidden="true"></span>
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{/each}
|
||||
{#if numbers?.length < 10}
|
||||
<div>
|
||||
<Layout.Stack direction="row" alignItems="flex-end" gap="xs">
|
||||
<InputOTP
|
||||
id={`draft-value-${index}`}
|
||||
bind:value={number.otp}
|
||||
placeholder="Enter value"
|
||||
label={savedNumbers.length === 0 && index === 0
|
||||
? 'Verification code'
|
||||
: undefined}
|
||||
maxlength={6}
|
||||
pattern={'^[0-9]{6}$'}
|
||||
patternError="The value must contain 6 digits"
|
||||
required>
|
||||
<Tooltip slot="end">
|
||||
<Input.Action
|
||||
icon={IconRefresh}
|
||||
on:click={() =>
|
||||
setDraftRow(index, { ...number, otp: generateOTP() })} />
|
||||
<span slot="tooltip">Regenerate</span>
|
||||
</Tooltip>
|
||||
</InputOTP>
|
||||
<Button
|
||||
secondary
|
||||
on:click={() =>
|
||||
addPhoneNumber({
|
||||
phone: generateNumber(),
|
||||
otp: generateOTP()
|
||||
})}
|
||||
disabled={numbers.length >= 10}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Add number
|
||||
compact
|
||||
disabled={!isValid(number) ||
|
||||
pendingRow === getDraftRowKey(number, index)}
|
||||
on:click={() => createPhoneNumber(number, index)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Empty
|
||||
on:click={() => {
|
||||
addPhoneNumber({
|
||||
phone: generateNumber(),
|
||||
otp: generateOTP()
|
||||
});
|
||||
}}>Generate number</Empty>
|
||||
<Button
|
||||
icon
|
||||
text
|
||||
disabled={pendingRow === getDraftRowKey(number, index)}
|
||||
ariaLabel="Delete mock phone number"
|
||||
on:click={() => deletePhoneNumber(number, index)}>
|
||||
<Icon icon={IconTrash} size="s" />
|
||||
</Button>
|
||||
{#if pendingRow === getDraftRowKey(number, index)}
|
||||
<span style:opacity="0.75">
|
||||
<Spinner size="s" />
|
||||
</span>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{/each}
|
||||
{#if savedNumbers.length + draftNumbers.length < 10}
|
||||
<div>
|
||||
<Button
|
||||
secondary
|
||||
on:click={addPhoneNumber}
|
||||
disabled={savedNumbers.length + draftNumbers.length >= 10}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Add number
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
{:else}
|
||||
<Empty on:click={addPhoneNumber}>Add a number</Empty>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
|
||||
<svelte:fragment slot="actions">
|
||||
<Button disabled={isSubmitDisabled} submit>Update</Button>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
</Form>
|
||||
<Confirm
|
||||
bind:open={showDeleteConfirm}
|
||||
bind:error={deleteError}
|
||||
title="Delete mock phone number"
|
||||
action="Delete"
|
||||
submissionLoader
|
||||
disabled={isDeletePending()}
|
||||
onSubmit={confirmDeletePhoneNumber}>
|
||||
{#if getDeleteTargetRow()}
|
||||
<p>
|
||||
Are you sure you want to delete <b>{getDeleteTargetRow()?.initialNumber}</b>?
|
||||
</p>
|
||||
{/if}
|
||||
</Confirm>
|
||||
|
||||
+1
-2
@@ -21,8 +21,7 @@
|
||||
|
||||
async function updateSessionLength() {
|
||||
try {
|
||||
await sdk.forConsole.projects.updateAuthDuration({
|
||||
projectId: project.$id,
|
||||
await sdk.forProject(project.region, project.$id).project.updateSessionDurationPolicy({
|
||||
duration: $baseValue
|
||||
});
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
|
||||
+2
-3
@@ -19,9 +19,8 @@
|
||||
|
||||
async function updateSessionsLimit() {
|
||||
try {
|
||||
await sdk.forConsole.projects.updateAuthSessionsLimit({
|
||||
projectId: project.$id,
|
||||
limit: maxSessions
|
||||
await sdk.forProject(project.region, project.$id).project.updateSessionLimitPolicy({
|
||||
total: maxSessions
|
||||
});
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
|
||||
|
||||
+2
-3
@@ -31,9 +31,8 @@
|
||||
|
||||
async function updateLimit() {
|
||||
try {
|
||||
await sdk.forConsole.projects.updateAuthLimit({
|
||||
projectId: project?.$id,
|
||||
limit: isLimited ? newLimit : 0
|
||||
await sdk.forProject(project.region, project.$id).project.updateUserLimitPolicy({
|
||||
total: isLimited ? newLimit : 0
|
||||
});
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
addNotification({
|
||||
|
||||
@@ -1,33 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { CardGrid } from '$lib/components';
|
||||
import { InputSwitch } from '$lib/elements/forms';
|
||||
import Button from '$lib/elements/forms/button.svelte';
|
||||
import { Container } from '$lib/layout';
|
||||
import { app } from '$lib/stores/app';
|
||||
import { authMethods, type AuthMethod } from '$lib/stores/auth-methods';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { oAuthProviders } from '$lib/stores/oauth-providers';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { MethodId, type Models } from '@appwrite.io/console';
|
||||
import { base } from '$app/paths';
|
||||
import { Avatar, Badge, Card, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Card,
|
||||
Dialog,
|
||||
Divider,
|
||||
Layout,
|
||||
Spinner,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import type { PageProps } from './$types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
/** Must stay derived from `data` so OAuth/auth toggles reflect `invalidate(Dependencies.PROJECT)` without a full reload. */
|
||||
const project = $derived(data.project);
|
||||
const resolvedOAuthProviders = $derived(data.oauthProviders);
|
||||
const consoleParamsMap = $derived(data.consoleParamsMap);
|
||||
|
||||
let showProvider = $state(false);
|
||||
let selectedProvider: Models.AuthProvider | null = $state(null);
|
||||
let isUpdatingAllAuthMethods = $state(false);
|
||||
let showUpdateAuthMethodsDialog = $state(false);
|
||||
let updateAuthMethodsEnabledMode = $state<boolean | null>(null);
|
||||
let apiAuthMethodUpdates = new SvelteSet<MethodId>();
|
||||
|
||||
const isAnyAuthMethodUpdating = $derived(apiAuthMethodUpdates.size > 0);
|
||||
const isAnyUpdateInProgress = $derived(isUpdatingAllAuthMethods || isAnyAuthMethodUpdating);
|
||||
|
||||
const allAuthMethodsEnabled = $derived.by(() => {
|
||||
if (isAnyUpdateInProgress) return false;
|
||||
return $authMethods.list.every((method) => method.value);
|
||||
});
|
||||
|
||||
const allAuthMethodsDisabled = $derived.by(() => {
|
||||
if (isAnyUpdateInProgress) return false;
|
||||
return $authMethods.list.every((method) => !method.value);
|
||||
});
|
||||
|
||||
const shouldDisableEnableAllButton = $derived(isAnyUpdateInProgress || allAuthMethodsEnabled);
|
||||
const shouldDisableDisableAllButton = $derived(isAnyUpdateInProgress || allAuthMethodsDisabled);
|
||||
|
||||
async function authUpdate(box: AuthMethod) {
|
||||
apiAuthMethodUpdates.add(box.method);
|
||||
try {
|
||||
await sdk.forConsole.projects.updateAuthStatus({
|
||||
projectId: project.$id,
|
||||
method: box.method,
|
||||
status: box.value
|
||||
await sdk.forProject(project.region, project.$id).project.updateAuthMethod({
|
||||
methodId: box.method,
|
||||
enabled: box.value
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -45,9 +79,68 @@
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.AuthStatusUpdate);
|
||||
} finally {
|
||||
apiAuthMethodUpdates.delete(box.method);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAllAuthMethods(status: boolean | null) {
|
||||
if (status === null) return;
|
||||
|
||||
isUpdatingAllAuthMethods = true;
|
||||
|
||||
try {
|
||||
const projectSdk = sdk.forProject(project.region, project.$id).project;
|
||||
for (const method of get(authMethods).list) {
|
||||
if (method.value === status) continue;
|
||||
await projectSdk.updateAuthMethod({
|
||||
methodId: method.method,
|
||||
enabled: status
|
||||
});
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
'All authentication methods for ' +
|
||||
project.name +
|
||||
' have been ' +
|
||||
(status ? 'enabled.' : 'disabled.')
|
||||
});
|
||||
trackEvent(Submit.AuthStatusUpdate);
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.AuthStatusUpdate);
|
||||
} finally {
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
isUpdatingAllAuthMethods = false;
|
||||
showUpdateAuthMethodsDialog = false;
|
||||
updateAuthMethodsEnabledMode = null;
|
||||
}
|
||||
}
|
||||
|
||||
const dialogDetails = $derived.by(() => {
|
||||
if (updateAuthMethodsEnabledMode) {
|
||||
return {
|
||||
title: 'Enable all auth methods',
|
||||
message: 'All authentication methods will be enabled.',
|
||||
actionButton: 'Enable all'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Disable all auth methods',
|
||||
message:
|
||||
'Are you sure you want to disable all authentication methods? This will prevent users from signing in until at least one method is re-enabled.',
|
||||
actionButton: 'Disable all'
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
authMethods.load(project);
|
||||
});
|
||||
@@ -60,24 +153,61 @@
|
||||
<svelte:fragment slot="title">Auth methods</svelte:fragment>
|
||||
Enable the authentication methods you wish to use.
|
||||
<svelte:fragment slot="aside">
|
||||
<Layout.Stack gap="l" direction="row" wrap="wrap">
|
||||
{#each $authMethods.list as box}
|
||||
<div style="flex-basis: 45%;">
|
||||
<InputSwitch
|
||||
label={box.label}
|
||||
id={box.method}
|
||||
bind:value={box.value}
|
||||
on:change={() => authUpdate(box)} />
|
||||
<Layout.Stack gap="m" class="auth-methods-list">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap="s"
|
||||
class="auth-methods-actions">
|
||||
<Button
|
||||
extraCompact
|
||||
on:click={() => {
|
||||
showUpdateAuthMethodsDialog = true;
|
||||
updateAuthMethodsEnabledMode = true;
|
||||
}}
|
||||
disabled={shouldDisableEnableAllButton}>Enable all</Button>
|
||||
<span style:height="20px">
|
||||
<Divider vertical />
|
||||
</span>
|
||||
<Button
|
||||
extraCompact
|
||||
on:click={() => {
|
||||
showUpdateAuthMethodsDialog = true;
|
||||
updateAuthMethodsEnabledMode = false;
|
||||
}}
|
||||
disabled={shouldDisableDisableAllButton}>Disable all</Button>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="l">
|
||||
<Divider class="auth-methods-divider" />
|
||||
<div class="auth-methods-grid">
|
||||
{#each $authMethods.list as box}
|
||||
<div class="auth-method-item">
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<InputSwitch
|
||||
label={box.label}
|
||||
id={box.method}
|
||||
bind:value={box.value}
|
||||
disabled={apiAuthMethodUpdates.has(box.method)}
|
||||
on:change={() => authUpdate(box)} />
|
||||
|
||||
{#if apiAuthMethodUpdates.has(box.method)}
|
||||
<span style:opacity="0.75">
|
||||
<Spinner size="s" />
|
||||
</span>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
<Layout.Stack>
|
||||
<Typography.Title size="s">OAuth2 Providers</Typography.Title>
|
||||
<ul class="grid-box" style:--grid-gap="1rem" style:--grid-item-size="15rem">
|
||||
{#each project.oAuthProviders
|
||||
.filter((p) => p.name !== 'Mock')
|
||||
{#each resolvedOAuthProviders
|
||||
.filter((p) => p.key !== 'mock' && p.name !== 'Mock')
|
||||
.sort( (a, b) => (a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1) ) as provider}
|
||||
{@const oAuthProvider = oAuthProviders[provider.key]}
|
||||
{#if oAuthProvider && !oAuthProvider.internal}
|
||||
@@ -124,8 +254,46 @@
|
||||
<OAuthProvider
|
||||
bind:provider={selectedProvider}
|
||||
bind:show={showProvider}
|
||||
parameters={consoleParamsMap.get(selectedProvider.key) ?? []}
|
||||
onclose={() => {
|
||||
selectedProvider = null;
|
||||
showProvider = false;
|
||||
}} />
|
||||
{/if}
|
||||
|
||||
<Dialog title={dialogDetails.title} bind:open={showUpdateAuthMethodsDialog}>
|
||||
<p class="text" data-private>{dialogDetails.message}</p>
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
|
||||
<Button text on:click={() => (showUpdateAuthMethodsDialog = false)}>Cancel</Button>
|
||||
|
||||
<Button
|
||||
secondary
|
||||
submissionLoader
|
||||
disabled={isUpdatingAllAuthMethods}
|
||||
forceShowLoader={isUpdatingAllAuthMethods}
|
||||
on:click={() => toggleAllAuthMethods(updateAuthMethodsEnabledMode)}>
|
||||
{dialogDetails.actionButton}
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.auth-methods-grid {
|
||||
display: grid;
|
||||
gap: var(--space-6) var(--space-8);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-method-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.auth-methods-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { oAuthProviders } from '$lib/stores/oauth-providers';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { Models, Models as ConsoleModels } from '@appwrite.io/console';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
/** Extract the appId equivalent from a typed OAuth2 provider model. */
|
||||
function extractAppId(p: Record<string, unknown>): string {
|
||||
return (p['clientId'] ??
|
||||
p['appId'] ??
|
||||
p['oauthClientId'] ??
|
||||
p['oauth2ClientId'] ??
|
||||
p['applicationId'] ??
|
||||
p['serviceId'] ??
|
||||
p['apiKey'] ??
|
||||
p['publicKey'] ??
|
||||
p['appKey'] ??
|
||||
p['keyString'] ??
|
||||
p['customerKey'] ??
|
||||
p['key'] ??
|
||||
'') as string;
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ depends, params }) => {
|
||||
depends(Dependencies.PROJECT);
|
||||
|
||||
const [providerList, consoleProviderList] = await Promise.all([
|
||||
sdk.forProject(params.region, params.project).project.listOAuth2Providers(),
|
||||
sdk.forConsole.console.listOAuth2Providers()
|
||||
]);
|
||||
|
||||
const consoleParamsMap = new Map<string, ConsoleModels.ConsoleOAuth2ProviderParameter[]>();
|
||||
for (const cp of consoleProviderList.oAuth2Providers) {
|
||||
consoleParamsMap.set(cp.$id, cp.parameters ?? []);
|
||||
}
|
||||
|
||||
const oauthProviders: Models.AuthProvider[] = providerList.providers
|
||||
.filter((p) => p.$id !== 'mock')
|
||||
.map((p) => {
|
||||
const raw = p as unknown as Record<string, unknown>;
|
||||
return {
|
||||
...raw,
|
||||
key: p.$id,
|
||||
name: oAuthProviders[p.$id]?.name ?? p.$id,
|
||||
appId: extractAppId(raw),
|
||||
secret: '',
|
||||
enabled: p.enabled
|
||||
};
|
||||
});
|
||||
|
||||
return { oauthProviders, consoleParamsMap };
|
||||
};
|
||||
@@ -1,25 +1,22 @@
|
||||
<script module lang="ts">
|
||||
import {
|
||||
EmailTemplateLocale,
|
||||
EmailTemplateType,
|
||||
SmsTemplateLocale,
|
||||
SmsTemplateType
|
||||
} from '@appwrite.io/console';
|
||||
import { EmailTemplateLocale, EmailTemplateType, type Models } from '@appwrite.io/console';
|
||||
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import type { EmailTemplateForm } from './store';
|
||||
|
||||
export async function loadEmailTemplate(
|
||||
region: string,
|
||||
projectId: string,
|
||||
type: EmailTemplateType,
|
||||
locale: EmailTemplateLocale
|
||||
) {
|
||||
locale: EmailTemplateLocale = EmailTemplateLocale.En
|
||||
): Promise<EmailTemplateForm> {
|
||||
try {
|
||||
return await sdk.forConsole.projects.getEmailTemplate({
|
||||
projectId,
|
||||
type: type,
|
||||
locale: locale
|
||||
const template = await sdk.forProject(region, projectId).project.getEmailTemplate({
|
||||
templateId: type,
|
||||
locale
|
||||
});
|
||||
return normalizeEmailTemplate(template, type, locale);
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -27,23 +24,18 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
export async function loadSmsTemplate(
|
||||
projectId: string,
|
||||
type: SmsTemplateType,
|
||||
locale: SmsTemplateLocale
|
||||
) {
|
||||
try {
|
||||
return await sdk.forConsole.projects.getSMSTemplate({
|
||||
projectId,
|
||||
type,
|
||||
locale
|
||||
});
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEmailTemplate(
|
||||
template: Models.EmailTemplate,
|
||||
type: EmailTemplateType,
|
||||
locale: EmailTemplateLocale = EmailTemplateLocale.En
|
||||
): EmailTemplateForm {
|
||||
return {
|
||||
...template,
|
||||
type,
|
||||
templateId: template.templateId ?? type,
|
||||
locale: template.locale ?? locale
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,7 +67,12 @@
|
||||
templateType = type;
|
||||
isTemplateLoading = true;
|
||||
|
||||
$emailTemplate = await loadEmailTemplate(page.params.project, type, EmailTemplateLocale.En);
|
||||
$emailTemplate = await loadEmailTemplate(
|
||||
page.params.region,
|
||||
page.params.project,
|
||||
type,
|
||||
EmailTemplateLocale.En
|
||||
);
|
||||
$baseEmailTemplate = { ...$emailTemplate };
|
||||
|
||||
isTemplateLoading = false;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.region,
|
||||
project.$id,
|
||||
EmailTemplateType.MfaChallenge,
|
||||
locale
|
||||
|
||||
+1
@@ -22,6 +22,7 @@
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.region,
|
||||
project.$id,
|
||||
EmailTemplateType.Invitation,
|
||||
locale
|
||||
|
||||
+1
@@ -22,6 +22,7 @@
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.region,
|
||||
project.$id,
|
||||
EmailTemplateType.MagicSession,
|
||||
locale
|
||||
|
||||
+1
@@ -22,6 +22,7 @@
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.region,
|
||||
project.$id,
|
||||
EmailTemplateType.OtpSession,
|
||||
locale
|
||||
|
||||
+1
@@ -22,6 +22,7 @@
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.region,
|
||||
project.$id,
|
||||
EmailTemplateType.Recovery,
|
||||
locale
|
||||
|
||||
+1
@@ -22,6 +22,7 @@
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.region,
|
||||
project.$id,
|
||||
EmailTemplateType.SessionAlert,
|
||||
locale
|
||||
|
||||
+16
-26
@@ -2,15 +2,10 @@
|
||||
import { Button, Form, InputEmail, InputText, InputTextarea } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import ResetEmail from './resetEmail.svelte';
|
||||
import { baseEmailTemplate, emailTemplate } from './store';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import {
|
||||
type EmailTemplateLocale,
|
||||
type EmailTemplateType,
|
||||
type Models
|
||||
} from '@appwrite.io/console';
|
||||
import { EmailTemplateLocale, type EmailTemplateType, type Models } from '@appwrite.io/console';
|
||||
import { Icon, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
import TemplateSkeleton from './templateSkeleton.svelte';
|
||||
@@ -28,20 +23,13 @@
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
let openResetModal = $state(false);
|
||||
let eventType = $state(Submit.EmailUpdateInviteTemplate);
|
||||
|
||||
const isSmtpEnabled = $derived(project?.smtpEnabled);
|
||||
const isButtonDisabled = $derived(deepEqual($emailTemplate, $baseEmailTemplate));
|
||||
|
||||
async function saveEmailTemplate() {
|
||||
if (!$emailTemplate.locale) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: 'Locale is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const locale = ($emailTemplate.locale || EmailTemplateLocale.En) as EmailTemplateLocale;
|
||||
|
||||
// TODO: uncomment after SDK is updated
|
||||
// if (!isValueOfStringEnum(TemplateType, $emailTemplate.type)) {
|
||||
@@ -65,16 +53,15 @@
|
||||
eventType = Submit.EmailUpdateVerificationTemplate;
|
||||
break;
|
||||
}
|
||||
// TODO: fix TemplateType and TemplateLocale typing once SDK is updated
|
||||
await sdk.forConsole.projects.updateEmailTemplate({
|
||||
projectId: project.$id,
|
||||
type: $emailTemplate.type as EmailTemplateType,
|
||||
locale: $emailTemplate.locale as EmailTemplateLocale,
|
||||
await sdk.forProject(project.region, project.$id).project.updateEmailTemplate({
|
||||
templateId: $emailTemplate.type as EmailTemplateType,
|
||||
locale,
|
||||
subject: $emailTemplate.subject || undefined,
|
||||
message: $emailTemplate.message || undefined,
|
||||
senderName: $emailTemplate.senderName || undefined,
|
||||
senderEmail: $emailTemplate.senderEmail || undefined,
|
||||
replyTo: $emailTemplate.replyTo || undefined
|
||||
replyToEmail: $emailTemplate.replyToEmail || undefined,
|
||||
replyToName: $emailTemplate.replyToName || undefined
|
||||
});
|
||||
|
||||
$baseEmailTemplate = {
|
||||
@@ -119,11 +106,17 @@
|
||||
disabled={!isSmtpEnabled} />
|
||||
|
||||
<InputEmail
|
||||
bind:value={$emailTemplate.replyTo}
|
||||
id="replyTo"
|
||||
label="Reply to"
|
||||
bind:value={$emailTemplate.replyToEmail}
|
||||
id="replyToEmail"
|
||||
label="Reply to email"
|
||||
placeholder="noreply@appwrite.io" />
|
||||
|
||||
<InputText
|
||||
bind:value={$emailTemplate.replyToName}
|
||||
id="replyToName"
|
||||
label="Reply to name"
|
||||
placeholder="Enter reply to name" />
|
||||
|
||||
{#if children}
|
||||
<p class="text">
|
||||
Click to copy variables for the fields below. Learn more <a
|
||||
@@ -162,10 +155,7 @@
|
||||
</Layout.Stack>
|
||||
<div class="u-sep-block-start u-margin-block-start-24"></div>
|
||||
<div class="u-flex u-gap-16 u-main-end u-margin-block-start-24">
|
||||
<Button on:click={() => (openResetModal = true)} text>Reset changes</Button>
|
||||
<Button submit disabled={isButtonDisabled}>Update</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<ResetEmail bind:show={openResetModal} {project} />
|
||||
|
||||
+1
@@ -22,6 +22,7 @@
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.region,
|
||||
project.$id,
|
||||
EmailTemplateType.Verification,
|
||||
locale
|
||||
|
||||
@@ -18,5 +18,4 @@
|
||||
placeholder="Select a language"
|
||||
name="locale"
|
||||
bind:value
|
||||
required
|
||||
{options} />
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { EmailTemplateLocale, EmailTemplateType, Models } from '@appwrite.io/console';
|
||||
import { loadEmailTemplate } from './+page.svelte';
|
||||
import { baseEmailTemplate, emailTemplate } from './store';
|
||||
import { Dialog, Layout, Alert } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
show = $bindable(false),
|
||||
project
|
||||
}: {
|
||||
show: boolean;
|
||||
project: Models.Project;
|
||||
} = $props();
|
||||
|
||||
let error: string = $state(null);
|
||||
|
||||
async function reset() {
|
||||
try {
|
||||
// TODO: fix TemplateType and TemplateLocale typing once SDK is updated
|
||||
await sdk.forConsole.projects.deleteEmailTemplate({
|
||||
projectId: project.$id,
|
||||
type: $emailTemplate.type as EmailTemplateType,
|
||||
locale: $emailTemplate.locale as EmailTemplateLocale
|
||||
});
|
||||
$emailTemplate = await loadEmailTemplate(
|
||||
project.$id,
|
||||
$emailTemplate.type as EmailTemplateType,
|
||||
$emailTemplate.locale as EmailTemplateLocale
|
||||
);
|
||||
$baseEmailTemplate = { ...$emailTemplate };
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Email template has been reset'
|
||||
});
|
||||
trackEvent(Submit.EmailResetTemplate, {
|
||||
locale: $emailTemplate.locale,
|
||||
type: $emailTemplate.type
|
||||
});
|
||||
|
||||
show = false;
|
||||
} catch (e) {
|
||||
trackError(e, Submit.EmailResetTemplate);
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={show} title="Reset changes">
|
||||
<svelte:fragment slot="title">Reset email template?</svelte:fragment>
|
||||
|
||||
<Layout.Stack>
|
||||
<p class="text">
|
||||
Are you sure you want to reset the email template?
|
||||
<b>Default values will be set in all inputs.</b>
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<Alert.Inline status="warning" title={error} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
|
||||
<Button text secondary on:click={() => (show = false)}>Cancel</Button>
|
||||
<Button on:click={reset}>Reset</Button>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Dialog>
|
||||
@@ -1,53 +0,0 @@
|
||||
<!-- <script lang="ts">
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Modal } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { SmsTemplateLocale, SmsTemplateType } from '@appwrite.io/console';
|
||||
import { project } from '../../store';
|
||||
import { loadSmsTemplate } from './+page.svelte';
|
||||
import { baseSmsTemplate, smsTemplate } from './store';
|
||||
|
||||
export let show = false;
|
||||
|
||||
let error: string;
|
||||
|
||||
async function reset() {
|
||||
try {
|
||||
// TODO: fix TemplateType and TemplateLocale typing once SDK is updated
|
||||
await sdk.forConsole.projects.deleteSmsTemplate(
|
||||
$project.$id,
|
||||
$smsTemplate.type as SmsTemplateType,
|
||||
$smsTemplate.locale as SmsTemplateLocale
|
||||
);
|
||||
|
||||
$smsTemplate = await loadSmsTemplate(
|
||||
$project.$id,
|
||||
$smsTemplate.type,
|
||||
$smsTemplate.locale
|
||||
);
|
||||
$baseSmsTemplate = { ...$smsTemplate };
|
||||
trackEvent(Submit.SmsResetTemplate, {
|
||||
locale: $smsTemplate.locale,
|
||||
type: $smsTemplate.type
|
||||
});
|
||||
show = false;
|
||||
} catch (e) {
|
||||
trackError(e, Submit.SmsResetTemplate);
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal onSubmit={reset} {error} bind:show>
|
||||
<svelte:fragment slot="title">Reset SMS template?</svelte:fragment>
|
||||
<p class="text">
|
||||
Are you sure you want to reset the SMS template?
|
||||
<b>Default values will be set in all inputs.</b>
|
||||
</p>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button text secondary on:click={() => (show = false)}>Cancel</Button>
|
||||
<Button secondary submit>Reset</Button>
|
||||
</svelte:fragment>
|
||||
</Modal> -->
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
<!-- <script lang="ts">
|
||||
import SmsTemplate from './smsTemplate.svelte';
|
||||
import LocaleOptions from './localeOptions.svelte';
|
||||
import { baseSmsTemplate, smsTemplate } from './store';
|
||||
import { page } from '$app/state';
|
||||
import { loadSmsTemplate } from './+page.svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Id } from '$lib/components';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
const projectId = page.params.project;
|
||||
let locale = 'en';
|
||||
let loading = false;
|
||||
|
||||
async function onLocaleChange() {
|
||||
const timeout = setTimeout(() => {
|
||||
loading = true;
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadSmsTemplate(projectId, 'invitation', locale);
|
||||
smsTemplate.set(template);
|
||||
$baseSmsTemplate = { ...$smsTemplate };
|
||||
trackEvent(Submit.SmsChangeLocale, { locale, type: 'invitation' });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.SmsChangeLocale);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="boxes-wrapper u-margin-block-start-16">
|
||||
<LocaleOptions on:change={onLocaleChange} bind:value={locale} />
|
||||
<SmsTemplate bind:loading>
|
||||
<Id value={'{{team}}'}>{'{{team}}'}</Id>
|
||||
<Id value={'{{user}}'}>{'{{user}}'}</Id>
|
||||
<Id value={'{{project}}'}>{'{{project}}'}</Id>
|
||||
<Id value={'{{redirect}}'}>{'{{redirect}}'}</Id>
|
||||
</SmsTemplate>
|
||||
</div> -->
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
<!-- <script lang="ts">
|
||||
import SmsTemplate from './smsTemplate.svelte';
|
||||
import LocaleOptions from './localeOptions.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { loadSmsTemplate } from './+page.svelte';
|
||||
import { baseSmsTemplate, smsTemplate } from './store';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Id } from '$lib/components';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
const projectId = page.params.project;
|
||||
let locale = 'en';
|
||||
let loading = false;
|
||||
|
||||
async function onLocaleChange() {
|
||||
const timeout = setTimeout(() => {
|
||||
loading = true;
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadSmsTemplate(projectId, 'login', locale);
|
||||
smsTemplate.set(template);
|
||||
$baseSmsTemplate = { ...$smsTemplate };
|
||||
trackEvent(Submit.SmsChangeLocale, { locale, type: 'login' });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.SmsChangeLocale);
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="boxes-wrapper u-margin-block-start-16">
|
||||
<LocaleOptions on:change={onLocaleChange} bind:value={locale} />
|
||||
<SmsTemplate bind:loading>
|
||||
<Id value={'{{team}}'}>{'{{team}}'}</Id>
|
||||
<Id value={'{{user}}'}>{'{{user}}'}</Id>
|
||||
<Id value={'{{project}}'}>{'{{project}}'}</Id>
|
||||
<Id value={'{{redirect}}'}>{'{{redirect}}'}</Id>
|
||||
</SmsTemplate>
|
||||
</div> -->
|
||||
@@ -1,96 +0,0 @@
|
||||
<!-- <script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Button, Form, InputTextarea } from '$lib/elements/forms';
|
||||
import ResetSms from './resetSms.svelte';
|
||||
import { baseSmsTemplate, smsTemplate } from './store';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Box } from '$lib/components';
|
||||
import type { SmsTemplateLocale, SmsTemplateType } from '@appwrite.io/console';
|
||||
|
||||
export let loading = false;
|
||||
const projectId = page.params.project;
|
||||
let openResetModal = false;
|
||||
|
||||
let eventType = Submit.SmsUpdateInviteTemplate;
|
||||
|
||||
async function saveSmsTemplate() {
|
||||
if (!$smsTemplate.locale) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: 'Locale is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
switch ($smsTemplate.type) {
|
||||
case 'invitation':
|
||||
eventType = Submit.SmsUpdateInviteTemplate;
|
||||
break;
|
||||
case 'login':
|
||||
eventType = Submit.SmsUpdateLoginTemplate;
|
||||
break;
|
||||
case 'verification':
|
||||
eventType = Submit.SmsUpdateVerificationTemplate;
|
||||
break;
|
||||
}
|
||||
// TODO: fix TemplateType and TemplateLocale typing once SDK is updated
|
||||
await sdk.forConsole.projects.updateSmsTemplate(
|
||||
projectId,
|
||||
$smsTemplate.type as SmsTemplateType,
|
||||
$smsTemplate.locale as SmsTemplateLocale,
|
||||
$smsTemplate.message
|
||||
);
|
||||
|
||||
$baseSmsTemplate = {
|
||||
...$smsTemplate
|
||||
};
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `SMS ${$smsTemplate.type} template for ${$smsTemplate.locale} updated`
|
||||
});
|
||||
trackEvent(eventType, {
|
||||
locale: $smsTemplate.locale
|
||||
});
|
||||
} catch (e) {
|
||||
trackError(e, eventType);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: isButtonDisabled = deepEqual($smsTemplate, $baseSmsTemplate);
|
||||
</script>
|
||||
|
||||
<Box>
|
||||
{#if loading}
|
||||
<div
|
||||
class="u-position-absolute u-width-full-line u-flex u-flex-vertical u-main-center u-cross-center u-gap-16 u-margin-block-start-32"
|
||||
style="inset-inline-start: 0;">
|
||||
<div class="loader"></div>
|
||||
<p class="text">Loading template...</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class:u-opacity-0={loading} style={loading ? 'pointer-events: none' : ''}>
|
||||
<Form onSubmit={saveSmsTemplate}>
|
||||
<InputTextarea
|
||||
bind:value={$smsTemplate.message}
|
||||
id="message"
|
||||
label="Message"
|
||||
placeholder="Enter your message" />
|
||||
|
||||
<div class="u-sep-block-start u-margin-block-start-24"></div>
|
||||
|
||||
<div class="u-flex u-gap-16 u-main-end u-margin-block-start-24">
|
||||
<Button on:click={() => (openResetModal = true)} text>Reset changes</Button>
|
||||
<Button submit disabled={isButtonDisabled}>Update</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<ResetSms bind:show={openResetModal} /> -->
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
<!-- <script lang="ts">
|
||||
import SmsTemplate from './smsTemplate.svelte';
|
||||
import LocaleOptions from './localeOptions.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { loadSmsTemplate } from './+page.svelte';
|
||||
import { baseSmsTemplate, smsTemplate } from './store';
|
||||
import { Id } from '$lib/components';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
const projectId = page.params.project;
|
||||
let locale = 'en';
|
||||
let loading = false;
|
||||
|
||||
async function onLocaleChange() {
|
||||
const timeout = setTimeout(() => {
|
||||
loading = true;
|
||||
}, 1000);
|
||||
try {
|
||||
const template = await loadSmsTemplate(projectId, 'verification', locale);
|
||||
smsTemplate.set(template);
|
||||
$baseSmsTemplate = { ...$smsTemplate };
|
||||
trackEvent(Submit.SmsChangeLocale, { locale, type: 'verification' });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.SmsChangeLocale);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="boxes-wrapper u-margin-block-start-16">
|
||||
<LocaleOptions on:change={onLocaleChange} bind:value={locale} />
|
||||
<SmsTemplate bind:loading>
|
||||
<Id value={'{{team}}'}>{'{{team}}'}</Id>
|
||||
<Id value={'{{user}}'}>{'{{user}}'}</Id>
|
||||
<Id value={'{{project}}'}>{'{{project}}'}</Id>
|
||||
<Id value={'{{redirect}}'}>{'{{redirect}}'}</Id>
|
||||
</SmsTemplate>
|
||||
</div> -->
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { EmailTemplateType, type Models } from '@appwrite.io/console';
|
||||
import { EmailTemplateType, type EmailTemplateLocale, type Models } from '@appwrite.io/console';
|
||||
|
||||
// component imports
|
||||
import Email2FaTemplate from './email2FATemplate.svelte';
|
||||
@@ -10,38 +10,36 @@ import EmailOtpSessionTemplate from './emailOtpSessionTemplate.svelte';
|
||||
import EmailVerificationTemplate from './emailVerificationTemplate.svelte';
|
||||
import EmailSessionAlertTemplate from './emailSessionAlertTemplate.svelte';
|
||||
|
||||
export const emailTemplate = writable<Models.EmailTemplate>({
|
||||
export type EmailTemplateForm = Models.EmailTemplate & {
|
||||
type: EmailTemplateType | null;
|
||||
templateId: EmailTemplateType | string | null;
|
||||
locale: EmailTemplateLocale | string | null;
|
||||
};
|
||||
|
||||
export const emailTemplate = writable<EmailTemplateForm>({
|
||||
templateId: null,
|
||||
type: null,
|
||||
locale: null,
|
||||
message: null,
|
||||
senderName: '',
|
||||
senderEmail: null,
|
||||
replyTo: null,
|
||||
replyToEmail: null,
|
||||
replyToName: null,
|
||||
subject: null
|
||||
});
|
||||
|
||||
export const baseEmailTemplate = writable<Models.EmailTemplate>({
|
||||
export const baseEmailTemplate = writable<EmailTemplateForm>({
|
||||
templateId: null,
|
||||
type: null,
|
||||
locale: null,
|
||||
message: null,
|
||||
senderName: null,
|
||||
senderEmail: null,
|
||||
replyTo: null,
|
||||
replyToEmail: null,
|
||||
replyToName: null,
|
||||
subject: null
|
||||
});
|
||||
|
||||
export const smsTemplate = writable<Models.SmsTemplate>({
|
||||
type: null,
|
||||
locale: null,
|
||||
message: null
|
||||
});
|
||||
|
||||
export const baseSmsTemplate = writable<Models.SmsTemplate>({
|
||||
type: null,
|
||||
locale: null,
|
||||
message: null
|
||||
});
|
||||
|
||||
export const templates = [
|
||||
{
|
||||
key: EmailTemplateType.Verification,
|
||||
|
||||
@@ -7,10 +7,12 @@ import { sdk } from '$lib/stores/sdk';
|
||||
import { OAuthProvider, type Models } from '@appwrite.io/console';
|
||||
|
||||
type Args = {
|
||||
region: string;
|
||||
projectId: string;
|
||||
provider: Models.AuthProvider;
|
||||
appId: string;
|
||||
appId: string | null;
|
||||
secret: string;
|
||||
details: Record<string, string>;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
@@ -19,11 +21,323 @@ type Return = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function updateOAuth({
|
||||
function parseSecret(secret: string) {
|
||||
if (!secret) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(secret);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProjectOAuth({
|
||||
region,
|
||||
projectId,
|
||||
provider,
|
||||
appId,
|
||||
secret,
|
||||
details,
|
||||
enabled
|
||||
}: Args) {
|
||||
const projectSdk = sdk.forProject(region, projectId).project;
|
||||
const parsedSecret = parseSecret(secret);
|
||||
|
||||
const getAppId = (): string => appId ?? '';
|
||||
const getDetail = (key: string): string => details[key] ?? '';
|
||||
const getSecret = (key?: string): string | undefined => {
|
||||
if (key) {
|
||||
const value = parsedSecret[key];
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
const firstValue = Object.values(parsedSecret).find(
|
||||
(value): value is string => typeof value === 'string' && value !== ''
|
||||
);
|
||||
|
||||
return firstValue;
|
||||
};
|
||||
|
||||
switch (provider.key) {
|
||||
case OAuthProvider.Amazon:
|
||||
return projectSdk.updateOAuth2Amazon({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Apple:
|
||||
return projectSdk.updateOAuth2Apple({
|
||||
serviceId: getAppId(),
|
||||
keyId: getDetail('keyId'),
|
||||
teamId: getDetail('teamId'),
|
||||
p8File: getSecret('p8File'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Auth0:
|
||||
return projectSdk.updateOAuth2Auth0({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret('clientSecret'),
|
||||
endpoint: getDetail('endpoint'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Authentik:
|
||||
return projectSdk.updateOAuth2Authentik({
|
||||
endpoint: getDetail('endpoint'),
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret('clientSecret'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Autodesk:
|
||||
return projectSdk.updateOAuth2Autodesk({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Bitbucket:
|
||||
return projectSdk.updateOAuth2Bitbucket({
|
||||
key: getAppId(),
|
||||
secret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Bitly:
|
||||
return projectSdk.updateOAuth2Bitly({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Box:
|
||||
return projectSdk.updateOAuth2Box({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Dailymotion:
|
||||
return projectSdk.updateOAuth2Dailymotion({
|
||||
apiKey: getAppId(),
|
||||
apiSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Discord:
|
||||
return projectSdk.updateOAuth2Discord({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Disqus:
|
||||
return projectSdk.updateOAuth2Disqus({
|
||||
publicKey: getAppId(),
|
||||
secretKey: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Dropbox:
|
||||
return projectSdk.updateOAuth2Dropbox({
|
||||
appKey: getAppId(),
|
||||
appSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Etsy:
|
||||
return projectSdk.updateOAuth2Etsy({
|
||||
keyString: getAppId(),
|
||||
sharedSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Facebook:
|
||||
return projectSdk.updateOAuth2Facebook({
|
||||
appId: getAppId(),
|
||||
appSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Figma:
|
||||
return projectSdk.updateOAuth2Figma({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Fusionauth:
|
||||
return projectSdk.updateOAuth2FusionAuth({
|
||||
endpoint: getDetail('endpoint'),
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret('clientSecret'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Github:
|
||||
return projectSdk.updateOAuth2GitHub({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Gitlab:
|
||||
return projectSdk.updateOAuth2Gitlab({
|
||||
applicationId: getAppId(),
|
||||
secret: getSecret('secret'),
|
||||
endpoint: getDetail('endpoint'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Google:
|
||||
return projectSdk.updateOAuth2Google({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Keycloak:
|
||||
return projectSdk.updateOAuth2Keycloak({
|
||||
endpoint: getDetail('endpoint'),
|
||||
realmName: getDetail('realmName'),
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret('clientSecret'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Kick:
|
||||
return projectSdk.updateOAuth2Kick({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Linkedin:
|
||||
return projectSdk.updateOAuth2Linkedin({
|
||||
clientId: getAppId(),
|
||||
primaryClientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Microsoft:
|
||||
return projectSdk.updateOAuth2Microsoft({
|
||||
tenant: getDetail('tenant'),
|
||||
applicationId: getAppId(),
|
||||
applicationSecret: getSecret('applicationSecret'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Notion:
|
||||
return projectSdk.updateOAuth2Notion({
|
||||
oauthClientId: getAppId(),
|
||||
oauthClientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Oidc:
|
||||
return projectSdk.updateOAuth2Oidc({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret('clientSecret'),
|
||||
wellKnownURL: getDetail('wellKnownURL'),
|
||||
authorizationURL: getDetail('authorizationURL'),
|
||||
tokenUrl: getDetail('tokenUrl'),
|
||||
userInfoUrl: getDetail('userInfoUrl'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Okta:
|
||||
return projectSdk.updateOAuth2Okta({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret('clientSecret'),
|
||||
domain: getDetail('domain'),
|
||||
authorizationServerId: getDetail('authorizationServerId'),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Paypal:
|
||||
return projectSdk.updateOAuth2Paypal({
|
||||
clientId: getAppId(),
|
||||
secretKey: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.PaypalSandbox:
|
||||
return projectSdk.updateOAuth2PaypalSandbox({
|
||||
clientId: getAppId(),
|
||||
secretKey: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Podio:
|
||||
return projectSdk.updateOAuth2Podio({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Salesforce:
|
||||
return projectSdk.updateOAuth2Salesforce({
|
||||
customerKey: getAppId(),
|
||||
customerSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Slack:
|
||||
return projectSdk.updateOAuth2Slack({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Spotify:
|
||||
return projectSdk.updateOAuth2Spotify({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Stripe:
|
||||
return projectSdk.updateOAuth2Stripe({
|
||||
clientId: getAppId(),
|
||||
apiSecretKey: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Tradeshift:
|
||||
return projectSdk.updateOAuth2Tradeshift({
|
||||
oauth2ClientId: getAppId(),
|
||||
oauth2ClientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.TradeshiftBox:
|
||||
return projectSdk.updateOAuth2TradeshiftSandbox({
|
||||
oauth2ClientId: getAppId(),
|
||||
oauth2ClientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Twitch:
|
||||
return projectSdk.updateOAuth2Twitch({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Wordpress:
|
||||
return projectSdk.updateOAuth2WordPress({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.X:
|
||||
return projectSdk.updateOAuth2X({
|
||||
customerKey: getAppId(),
|
||||
secretKey: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Yahoo:
|
||||
return projectSdk.updateOAuth2Yahoo({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Yandex:
|
||||
return projectSdk.updateOAuth2Yandex({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Zoho:
|
||||
return projectSdk.updateOAuth2Zoho({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
case OAuthProvider.Zoom:
|
||||
return projectSdk.updateOAuth2Zoom({
|
||||
clientId: getAppId(),
|
||||
clientSecret: getSecret(),
|
||||
enabled
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unsupported OAuth2 provider: ${provider.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOAuth({
|
||||
region,
|
||||
projectId,
|
||||
provider,
|
||||
appId,
|
||||
secret,
|
||||
details,
|
||||
enabled
|
||||
}: Args): Promise<Return> {
|
||||
try {
|
||||
@@ -31,13 +345,7 @@ export async function updateOAuth({
|
||||
throw new Error(`Invalid OAuth2 provider: ${provider.key}`);
|
||||
}
|
||||
|
||||
await sdk.forConsole.projects.updateOAuth2({
|
||||
projectId,
|
||||
provider: provider.key,
|
||||
appId: appId || undefined,
|
||||
secret: secret || undefined,
|
||||
enabled
|
||||
});
|
||||
await updateProjectOAuth({ region, projectId, provider, appId, secret, details, enabled });
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
|
||||
addNotification({
|
||||
|
||||
@@ -25,11 +25,7 @@
|
||||
message: `${user.name ? user.name : 'User'} has been deleted`
|
||||
});
|
||||
trackEvent(Submit.UserDelete);
|
||||
await goto(
|
||||
resolveRoute('/(console)/project-[region]-[project]/auth', {
|
||||
...page.params
|
||||
})
|
||||
);
|
||||
await goto(resolveRoute('/(console)/project-[region]-[project]/auth', page.params));
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
trackError(e, Submit.UserDelete);
|
||||
|
||||
+20
-9
@@ -45,6 +45,15 @@
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getProviderMeta(providerId: string) {
|
||||
const provider = oAuthProviders[providerId];
|
||||
|
||||
return {
|
||||
icon: provider?.icon,
|
||||
name: provider?.name ?? providerId
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<MultiSelectionTable {columns} resource="identity" onDelete={handleDelete}>
|
||||
@@ -66,16 +75,18 @@
|
||||
</Id>
|
||||
{/key}
|
||||
{:else if column.id === 'provider'}
|
||||
{@const provider = oAuthProviders[identity[column.id]]}
|
||||
{@const provider = getProviderMeta(identity[column.id])}
|
||||
<div class="u-inline-flex u-cross-center u-gap-8">
|
||||
<div class="avatar is-size-small">
|
||||
<img
|
||||
style="--p-text-size: 1rem"
|
||||
height="20"
|
||||
width="20"
|
||||
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
|
||||
alt={provider.name} />
|
||||
</div>
|
||||
{#if provider.icon}
|
||||
<div class="avatar is-size-small">
|
||||
<img
|
||||
style="--p-text-size: 1rem"
|
||||
height="20"
|
||||
width="20"
|
||||
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
|
||||
alt={provider.name} />
|
||||
</div>
|
||||
{/if}
|
||||
{provider.name}
|
||||
</div>
|
||||
{:else if column.type === 'datetime'}
|
||||
|
||||
+79
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 205 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user