Merge remote-tracking branch 'origin/main' into feat/project-premium-geo-db-addon

# Conflicts:
#	bun.lock
#	package.json
This commit is contained in:
Damodar Lohani
2026-04-26 05:15:05 +00:00
48 changed files with 1413 additions and 695 deletions
+47 -4
View File
@@ -6,12 +6,20 @@
"name": "@appwrite/console",
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@95547fc",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@88c189e",
"@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@95547fc", { "dependencies": { "json-bigint": "1.0.0" } }],
"@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@88c189e", { "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
View File
@@ -20,7 +20,7 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@95547fc",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@88c189e",
"@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",
+125
View File
@@ -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();
}
@@ -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>
+35
View File
@@ -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
+6
View File
@@ -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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 KiB

+2 -2
View File
@@ -29,8 +29,8 @@
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!
+9 -9
View File
@@ -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
}
];
+12 -2
View File
@@ -30,8 +30,18 @@ function createHeaderAlertStore() {
}
});
},
updateShow: (id: string, show: boolean) => {
update((n) => {
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 component
// return highest importance visible component
let component = {
id: '',
show: false,
@@ -40,7 +50,7 @@ function createHeaderAlertStore() {
};
update((n) => {
n.components.forEach((c) => {
if (c.importance > component.importance) {
if (c.show && c.importance > component.importance) {
component = c;
}
});
+15
View File
@@ -1,4 +1,5 @@
import { isMultiRegionSupported, VARS } from '$lib/system';
import { registerImpersonationClients, restoreImpersonation } from '$lib/appwrite/impersonation';
import {
Account,
Assistant,
@@ -68,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();
@@ -76,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 = {
@@ -139,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);
+10 -9
View File
@@ -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 WebhooksApi from '$lib/images/promos/webhooks-api.png';
import TerraformProvider from '$lib/images/promos/terraform-provider.png';
const listOfPromotions: BottomModalAlertItem[] = [];
if (isCloud) {
const webhooksApiPromo: BottomModalAlertItem = {
id: 'modal:webhooks_api_announcement',
const terraformProviderPromo: BottomModalAlertItem = {
id: 'modal:terraform_provider_announcement',
src: {
dark: WebhooksApi,
light: WebhooksApi
dark: TerraformProvider,
light: TerraformProvider
},
title: 'Announcing the Webhooks API',
message: 'Manage webhooks programmatically with Server SDKs and API keys.',
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-webhooks-api',
link: () => 'https://appwrite.io/blog/post/introducing-terraform-provider-for-appwrite',
external: true,
hideOnClick: true,
skipUpgradeRedirect: true
},
show: true
};
listOfPromotions.push(webhooksApiPromo);
listOfPromotions.push(terraformProviderPromo);
}
export function addBottomModalAlerts() {
@@ -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
});
@@ -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
});
@@ -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>
@@ -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>
@@ -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);
@@ -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);
@@ -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,19 +1,31 @@
<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. */
@@ -21,13 +33,33 @@
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 +77,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,16 +151,49 @@
<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)} />
</div>
{/each}
<Layout.Stack gap="m" class="auth-methods-list">
<Layout.Stack direction="row" alignItems="center" gap="s">
<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 />
<Layout.Stack direction="row" wrap="wrap">
{#each $authMethods.list as box}
<span style:flex-basis="30%">
<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>
</span>
{/each}
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</CardGrid>
@@ -129,3 +253,21 @@
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>
@@ -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
@@ -22,6 +22,7 @@
}, 1000);
try {
const template = await loadEmailTemplate(
project.region,
project.$id,
EmailTemplateType.Invitation,
locale
@@ -22,6 +22,7 @@
}, 1000);
try {
const template = await loadEmailTemplate(
project.region,
project.$id,
EmailTemplateType.MagicSession,
locale
@@ -22,6 +22,7 @@
}, 1000);
try {
const template = await loadEmailTemplate(
project.region,
project.$id,
EmailTemplateType.OtpSession,
locale
@@ -22,6 +22,7 @@
}, 1000);
try {
const template = await loadEmailTemplate(
project.region,
project.$id,
EmailTemplateType.Recovery,
locale
@@ -22,6 +22,7 @@
}, 1000);
try {
const template = await loadEmailTemplate(
project.region,
project.$id,
EmailTemplateType.SessionAlert,
locale
@@ -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} />
@@ -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> -->
@@ -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> -->
@@ -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} /> -->
@@ -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,
@@ -88,7 +88,7 @@
return [...idColumn, ...columns.filter((column) => !column.isAction), ...systemColumns];
}
$: selected = preferences.getCustomTableColumns(page.params.table);
$: selected = preferences.getCustomTableColumns(table.$id);
$: if (table.fields) {
const freshColumns = createTableColumns(table.fields, selected);
@@ -154,7 +154,7 @@
onDestroy(() => ($showCreateColumnSheet.show = false));
</script>
{#key page.params.table}
{#key table.$id}
<Container expanded expandHeightButton style="background: var(--bgcolor-neutral-primary)">
<Layout.Stack direction="column" gap="xl">
<Layout.Stack direction="row" justifyContent="space-between">
@@ -22,7 +22,8 @@
indexes,
isTablesCsvImportInProgress,
reorderItems,
showCreateIndexSheet
showCreateIndexSheet,
INTERNAL_ACTIONS_COLUMN_ID
} from '../store';
import EditColumn from './edit.svelte';
import DeleteColumn from './deleteColumn.svelte';
@@ -335,7 +336,7 @@
minimumWidth: 200,
resizable: true
},
{ id: 'actions', width: 40, isAction: true, resizable: false }
{ id: INTERNAL_ACTIONS_COLUMN_ID, width: 40, isAction: true, resizable: false }
]);
$effect(() => {
@@ -387,7 +388,7 @@
<Spreadsheet.Header.Cell column="indexed" {root}>Indexed</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="default" {root}
>Default value</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="actions" {root} />
<Spreadsheet.Header.Cell column={INTERNAL_ACTIONS_COLUMN_ID} {root} />
</svelte:fragment>
{#each updatedColumnsForSheet as column, index (column.key)}
@@ -543,7 +544,7 @@
{_default}
{/if}
</Spreadsheet.Cell>
<Spreadsheet.Cell column="actions" {root} isEditable={false}>
<Spreadsheet.Cell column={INTERNAL_ACTIONS_COLUMN_ID} {root} isEditable={false}>
{#if $isTablesCsvImportInProgress}
<CsvDisabled>
<Button disabled text icon ariaLabel="more options">
@@ -19,7 +19,11 @@
import { showColumnsSuggestionsModal } from '$database/(suggestions)/store';
import IconAINotification from '$database/(suggestions)/icon/aiNotification.svelte';
import { type Columns } from '$database/store';
import { type ColumnDirection, showCreateColumnSheet } from '$database/table-[table]/store';
import {
type ColumnDirection,
showCreateColumnSheet,
INTERNAL_ACTIONS_COLUMN_ID
} from '$database/table-[table]/store';
import { isCloud } from '$lib/system';
import { slide } from 'svelte/transition';
@@ -81,9 +85,9 @@
function insertColumnInOrder() {
if (!key) return;
const currentOrder = columnsOrder?.length
? columnsOrder
: columns?.map((col) => col.id) || [];
const currentOrder = (
columnsOrder?.length ? columnsOrder : columns?.map((col) => col.id) || []
).filter((columnId) => columnId !== '$id' && columnId !== INTERNAL_ACTIONS_COLUMN_ID);
// if the length is empty,
// means there's no ordering done.
@@ -93,12 +97,7 @@
let newOrder: string[];
if (!direction || !direction.neighbour) {
// Find the actions column position
const actionsIndex = currentOrder.indexOf('actions');
const beforeActionsOrder =
actionsIndex !== -1 ? currentOrder.slice(0, actionsIndex) : currentOrder;
const lastTwo = beforeActionsOrder.slice(-2);
const lastTwo = currentOrder.slice(-2);
const hasTimestampColumnsAtEnd =
lastTwo.length === 2 &&
lastTwo.includes('$createdAt') &&
@@ -112,8 +111,7 @@
currentOrder.indexOf('$updatedAt')
);
} else {
// Insert at the end, but before actions
insertIndex = actionsIndex !== -1 ? actionsIndex : currentOrder.length;
insertIndex = currentOrder.length;
}
newOrder = [
@@ -125,14 +123,7 @@
const neighbourIndex = currentOrder.indexOf(direction.neighbour);
if (neighbourIndex === -1) {
const actionsIndex = currentOrder.indexOf('actions');
const insertIndex = actionsIndex !== -1 ? actionsIndex : currentOrder.length;
newOrder = [
...currentOrder.slice(0, insertIndex),
key,
...currentOrder.slice(insertIndex)
];
newOrder = [...currentOrder, key];
} else {
const insertIndex = direction.to === 'left' ? neighbourIndex : neighbourIndex + 1;
newOrder = [
@@ -34,7 +34,8 @@
paginatedRows,
paginatedRowsLoading,
databaseRelatedRowSheetOptions,
rowPermissionSheet
rowPermissionSheet,
INTERNAL_ACTIONS_COLUMN_ID
} from '$database/table-[table]/store';
import { buildFieldUrl } from '$database/(entity)/helpers/navigation';
import type { Column, ColumnType } from '$lib/helpers/types';
@@ -167,7 +168,7 @@
function setupColumns() {
const order = preferences.getColumnOrder(tableId);
const systemColumns = new Set(['$id', 'actions']);
const systemColumns = new Set(['$id', INTERNAL_ACTIONS_COLUMN_ID]);
const validColumnKeys = new Set([
...$columns.map((col) => col.key),
@@ -209,6 +210,7 @@
function makeTableColumns() {
const selectedColumnsToHide = preferences.getCustomTableColumns(tableId);
const staticColumnIds = new Set(['$id', INTERNAL_ACTIONS_COLUMN_ID]);
const baseColumns: Column[] = table.fields.map((field: Field) => {
return {
@@ -263,7 +265,7 @@
hide: !!selectedColumnsToHide?.includes('$updatedAt')
},
{
id: 'actions',
id: INTERNAL_ACTIONS_COLUMN_ID,
title: '',
width: 40,
isAction: true,
@@ -279,7 +281,10 @@
const fixedRightColumn = staticColumns[3];
const reorderableColumns = [...baseColumns, staticColumns[1], staticColumns[2]];
const reorderedColumns = reorderItems(reorderableColumns, $columnsOrder);
const reorderedColumns = reorderItems(
reorderableColumns,
$columnsOrder.filter((columnId) => !staticColumnIds.has(columnId))
);
const finalColumns = [fixedLeftColumn, ...reorderedColumns, fixedRightColumn];
@@ -883,7 +888,7 @@
<svelte:fragment slot="header" let:root>
{#each $tableColumns as column (column.id)}
{#if column.isAction}
<Spreadsheet.Header.Cell column="actions" {root}>
<Spreadsheet.Header.Cell column={INTERNAL_ACTIONS_COLUMN_ID} {root}>
<Tooltip>
<Button.Button
icon
@@ -1040,7 +1045,7 @@
showDatetime
time={row[columnId]}
canShowPopover={canShowDatetimePopover} />
{:else if columnId === 'actions'}
{:else if columnId === INTERNAL_ACTIONS_COLUMN_ID}
<SpreadsheetOptions
type="row"
column={rowColumn}
@@ -12,6 +12,8 @@ export const indexes = derived(page, ($page) => $page.data.table.indexes as Mode
export const tableColumns = writable<Column[]>([]);
export const INTERNAL_ACTIONS_COLUMN_ID = '$actions';
export const isTablesCsvImportInProgress = writable(false);
export const columnsOrder = writable<string[]>([]);
@@ -12,7 +12,7 @@
import { resolve } from '$app/paths';
import deepEqual from 'deep-equal';
import { currentPlan } from '$lib/stores/organization';
import { type SMTPSecure } from '@appwrite.io/console';
import { type Secure } from '@appwrite.io/console';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import { getChangePlanUrl } from '$lib/stores/billing';
import { Link, Selector, Alert } from '@appwrite.io/pink-svelte';
@@ -25,7 +25,8 @@
let enabled: boolean = $state(false);
let replyTo: string = $state('');
let replyToEmail: string = $state('');
let replyToName: string = $state('');
let senderName: string = $state('');
let senderEmail: string = $state('');
@@ -49,7 +50,8 @@
enabled,
senderName,
senderEmail,
replyTo,
replyToEmail,
replyToName,
host,
port: port ?? '',
username,
@@ -60,7 +62,8 @@
enabled: project.smtpEnabled,
senderName: project.smtpSenderName,
senderEmail: project.smtpSenderEmail,
replyTo: project.smtpReplyTo,
replyToEmail: project.smtpReplyToEmail,
replyToName: project.smtpReplyToName,
host: project.smtpHost,
port: project.smtpPort,
username: project.smtpUsername,
@@ -72,17 +75,17 @@
async function updateSmtp() {
try {
await sdk.forConsole.projects.updateSMTP({
projectId: project.$id,
await sdk.forProject(project.region, project.$id).project.updateSMTP({
enabled,
senderName: senderName || undefined,
senderEmail: senderEmail || undefined,
replyTo: replyTo || undefined,
replyToEmail: replyToEmail || undefined,
replyToName: replyToName || undefined,
host: host || undefined,
port: port || undefined,
username: username || undefined,
password: password || undefined,
secure: secure ? (secure as SMTPSecure) : undefined
secure: secure ? (secure as Secure) : undefined
});
invalidate(Dependencies.PROJECT);
@@ -104,7 +107,8 @@
enabled = project.smtpEnabled ?? false;
senderName = project.smtpSenderName;
senderEmail = project.smtpSenderEmail;
replyTo = project.smtpReplyTo;
replyToEmail = project.smtpReplyToEmail;
replyToName = project.smtpReplyToName;
host = project.smtpHost;
port = project.smtpPort;
username = project.smtpUsername;
@@ -116,7 +120,8 @@
if (!enabled) {
senderName = '';
senderEmail = '';
replyTo = '';
replyToEmail = '';
replyToName = '';
host = '';
port = null;
username = '';
@@ -174,10 +179,15 @@
required
placeholder="user@example.io" />
<InputEmail
id="replyTo"
label="Reply to"
bind:value={replyTo}
id="replyToEmail"
label="Reply to email"
bind:value={replyToEmail}
placeholder="user@example.io" />
<InputText
id="replyToName"
label="Reply to name"
bind:value={replyToName}
placeholder="Enter reply to name" />
<InputText
id="serverHost"
label="Server host"
@@ -43,7 +43,7 @@
apiProtocolUpdates.add(protocol.method);
try {
await sdk.forProject($project.region, $project.$id).project.updateProtocolStatus({
await sdk.forProject($project.region, $project.$id).project.updateProtocol({
protocolId: protocol.method,
enabled: protocol.value
});
@@ -78,7 +78,7 @@
const projectSdk = sdk.forProject($project.region, $project.$id);
for (const protocol of get(protocols).list) {
if (protocol.value === status) continue;
await projectSdk.project.updateProtocolStatus({
await projectSdk.project.updateProtocol({
protocolId: protocol.method,
enabled: status
});
@@ -207,7 +207,7 @@
<style>
.protocols-list {
max-width: 36rem;
width: 100%;
}
.protocol-toolbar {
@@ -42,7 +42,7 @@
apiServiceUpdates.add(service.method);
try {
await sdk.forProject($project.region, $project.$id).project.updateServiceStatus({
await sdk.forProject($project.region, $project.$id).project.updateService({
serviceId: service.method,
enabled: service.value
});
@@ -77,7 +77,7 @@
const projectSdk = sdk.forProject($project.region, $project.$id);
for (const s of get(services).list) {
if (s.value === status) continue;
await projectSdk.project.updateServiceStatus({
await projectSdk.project.updateService({
serviceId: s.method,
enabled: status
});
+18
View File
@@ -13,6 +13,12 @@
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { loading } from '$routes/store';
import {
impersonationRevision,
readImpersonationTargetUserId
} from '$lib/appwrite/impersonation';
import { headerAlert } from '$lib/stores/headerAlert';
import ImpersonationBanner from '$lib/components/impersonation/banner.svelte';
import { Root } from '@appwrite.io/pink-svelte';
import { ThemeDark, ThemeLight, ThemeDarkCloud, ThemeLightCloud } from '../themes';
import { isSmallViewport, updateViewport } from '$lib/stores/viewport';
@@ -29,6 +35,13 @@
}
onMount(async () => {
headerAlert.add({
id: 'impersonation',
component: ImpersonationBanner,
show: !!readImpersonationTargetUserId(),
importance: 100 // highest priority — always visible when active
});
updateViewport();
// handle sources
if (isCloud) {
@@ -117,6 +130,11 @@
}
});
$: if (browser) {
void $impersonationRevision;
headerAlert.updateShow('impersonation', !!readImpersonationTargetUserId());
}
$: {
if (browser) {
const isCloudClass = isCloud ? 'is-cloud' : '';