UI/org work

This commit is contained in:
c0repwn3r 2023-02-28 14:13:18 -05:00
parent 7a074bf38f
commit 58be3b0c4a
Signed by: core
GPG Key ID: FDBF740DADDCEECF
14 changed files with 503 additions and 13 deletions

View File

@ -0,0 +1,14 @@
<script lang="ts">
export let title;
export let subtitle;
</script>
<div class="flex in-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
<!-- Title -->
<div>
<h1 class="font-semibold text-2xl">{title}</h1>
<h2 class="ftext-sm">{subtitle}</h2>
</div>
</div>
</div>

View File

@ -7,6 +7,10 @@
import {getCookie} from "$lib/cookie"; import {getCookie} from "$lib/cookie";
import {setCookie} from "$lib/cookie"; import {setCookie} from "$lib/cookie";
import {get_user_info, renderDevMenu} from "$lib/auth"; import {get_user_info, renderDevMenu} from "$lib/auth";
import {browser} from "$app/environment";
let loggedin;
if (browser) { loggedin = getCookie("authToken") !== "" } else { loggedin = false };
function toggleTheme() { function toggleTheme() {
if ($theme === "dark") { if ($theme === "dark") {
@ -50,7 +54,13 @@
<header> <header>
<nav class="flex items-center justify-between flex-wrap slate-300 dark:bg-slate-800 p-6 shadow-md"> <nav class="flex items-center justify-between flex-wrap slate-300 dark:bg-slate-800 p-6 shadow-md">
<span class="font-semibold text-xl tracking-tight">{t("common.appName")}</span> <div>
<span class="font-semibold text-xl tracking-tight">{t("common.appName")}</span>
{#if loggedin}
<a class="text-md ml-5 decoration-none hover:underline" href="/admin">Organizations</a>
<a class="text-md ml-5 decoration-none hover:underline" href="/settings">Settings</a>
{/if}
</div>
<div class="inline-block text-sm leading-none lg:m-0"> <div class="inline-block text-sm leading-none lg:m-0">
<div class="relative inline-block"> <div class="relative inline-block">

View File

@ -123,6 +123,9 @@ export async function enforce_auth(): Promise<[boolean, string]> {
} else { } else {
// session ok // session ok
logger.info("MFA token is OK"); logger.info("MFA token is OK");
if (browser) {
document.documentElement.classList.add("loggedin");
}
return [true, `${session_token} ${auth_token}`]; return [true, `${session_token} ${auth_token}`];
} }
} catch (e) { } catch (e) {

View File

@ -69,5 +69,29 @@
"apierror": { "apierror": {
"Invalid TOTP code": "Incorrect 2FA code" "Invalid TOTP code": "Incorrect 2FA code"
} }
},
"error": {
"code": {
"404": "Not found :("
},
"message": {
"Not Found": "We couldn't find this page. Try going back to the homepage."
}
},
"neworg": {
"title": "Create an organization",
"subtitle": "An organization is how you manage devices on your network. You can only own one, but can join organizations created by others.",
"iprangeprompt": "What IP range(s) would you like to use?",
"actionButtonText": "Create organization",
"cidrhelp": "Please provide it in CIDR notation. If providing multiple, please comma-separate them.",
"firstcert": "Configure your first CA",
"cahelp": "These settings cannot be changed. Please be careful.",
"additionalConstraints": "Add additional constraints (advanced)",
"subnetprompt": "What subnets would you like to allow?",
"subnethelp": "Comma-separated list of subnets in CIDR notation. This will constrain which subnets can be applied to client certs. Default: empty (any)",
"groupprompt": "What groups would you like to allow?",
"grouphelp": "Comma-separated list of groups. This will constrain which groups can be applied to client certs. Default: empty (any)"
} }
} }

77
tfweb/src/lib/orgs.ts Normal file
View File

@ -0,0 +1,77 @@
import {Logger, logSetup} from "./logger";
import {fetch_timeout} from "./util";
import {API_ROOT} from "./config";
import type {UserInfo} from "./auth";
const logger = new Logger("orgs.ts");
logSetup();
export interface Organization {
org_id: number,
owner_id: number,
ca_crts: string,
authorized_users: number[],
name: string
}
export async function list_org(api_key: string): Promise<number[] | string> {
logger.info(`Getting list of orgs associated with this user`);
try {
const resp = await fetch_timeout(`${API_ROOT}/v1/orgs`, {
'method': 'GET',
'headers': {
'Authorization': `Bearer ${api_key}`
}
});
if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`error fetching user information: ${rawerror}`);
return rawerror;
}
return JSON.parse(await resp.text()).org_ids;
} catch (e) {
logger.error(`Error fetching org list: ${e}`);
return `${e}`
}
}
export async function org(org: number, api_key: string): Promise<Organization | string> {
logger.info(`Getting information about org ${org}`);
let org_resp: Organization;
try {
const resp = await fetch_timeout(`${API_ROOT}/v1/org/${org}`, {
'method': 'GET',
'headers': {
'Authorization': `Bearer ${api_key}`
}
});
if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`error fetching user information: ${rawerror}`);
return rawerror;
}
org_resp = JSON.parse(await resp.text()) as Organization;
} catch (e) {
logger.error(`Error fetching org list: ${e}`);
return `${e}`
}
logger.info(`Getting user email for ${org_resp.owner_id}`);
try {
const resp = await fetch_timeout(`${API_ROOT}/v1/user/${org_resp.owner_id}`, {
'method': 'GET',
'headers': {
'Authorization': `Bearer ${api_key}`
}
});
if (!resp.ok) {
const rawerror = JSON.parse(await resp.text()).errors[0].message;
logger.error(`error fetching user information: ${rawerror}`);
return rawerror;
}
org_resp.name = JSON.parse(await resp.text()).email + "'s organization";
} catch (e) {
logger.error(`Error fetching owner email: ${e}`);
return `${e}`
}
return org_resp;
}

View File

@ -0,0 +1,14 @@
<script lang="ts">
import FullPageError from "../components/FullPageError.svelte";
import {t} from "$lib/i18n";
import {page} from "$app/stores";
import {onMount} from "svelte";
import {enforce_auth} from "$lib/auth";
onMount(async () => {
await enforce_auth(); // This is purely just to trigger loggedin for the navbar. We don't actually do anything with this.
})
</script>
<FullPageError title="{t('error.code.' + $page.status)}" subtitle="{t('error.message.' + $page.error.message)}"/>

View File

@ -1,11 +1,31 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"; import {onMount} from "svelte";
import {enforce_auth, enforce_session} from "../../lib/auth"; import {enforce_auth, enforce_session} from "$lib/auth";
import {Logger, logSetup} from "../../lib/logger"; import {Logger, logSetup} from "$lib/logger";
import {list_org} from "$lib/orgs";
import {t} from "$lib/i18n";
import FullPageError from "../../components/FullPageError.svelte";
import {get_user_info} from "$lib/auth";
import {org} from "$lib/orgs";
import type {Organization} from "$lib/orgs";
let logger = new Logger("admin/+page.svelte"); let logger = new Logger("admin/+page.svelte");
logSetup(); logSetup();
let api_token = "";
let fullPageError = false;
let fullPageErrorTitle = "";
let fullPageErrorSubtitle = "";
let org_list;
let user_info;
let owned_org_id;
let owned_org_orginfo;
let owns_org = false;
let orgs = {};
// this page requires session and mfa auth. // this page requires session and mfa auth.
onMount(async () => { onMount(async () => {
let st_result = await enforce_session(); let st_result = await enforce_session();
@ -20,8 +40,128 @@
window.location = "/auth/mfa"; window.location = "/auth/mfa";
return; return;
} }
const api_token = at_result[1];
// user is fully authenticated and permitted to proceed // user is fully authenticated and permitted to proceed
api_token = at_result[1];
logger.info("User authenticated successfully");
// GET ORG LIST
// we need to know what orgs this user has access to
org_list = await list_org(api_token);
if (typeof org_list === "string") {
// Error
logger.error(org_list);
fullPageError = true;
fullPageErrorTitle = t('admin.apierror.loadorg');
fullPageErrorSubtitle = t('admin.apierror.' + org_list);
return;
}
// GET USER INFO
user_info = await get_user_info(api_token);
if (typeof user_info === "string") {
logger.error(user_info);
fullPageError = true;
fullPageErrorTitle = t('admin.apierror.loaduser');
fullPageErrorSubtitle = t('admin.apierror.' + user_info);
return;
}
owned_org_id = user_info.data.actor.organizationID;
if (owned_org_id !== "") {
// the user actually owns an org
logger.info("User owns an org");
owns_org = true;
owned_org_orginfo = await org(owned_org_id as number, api_token);
if (typeof owned_org_orginfo === "string") {
logger.error(owned_org_orginfo);
fullPageError = true;
fullPageErrorTitle = t('admin.apierror.loadownedorg');
fullPageErrorSubtitle = t('admin.apierror.' + owned_org_orginfo);
return;
}
} else {
logger.info("User does not own an org");
}
// GET INFO ABOUT ALL THE ORGS
for (let i = 0; i < org_list.length; i++) {
let org_id = org_list[i];
if (org_id === owned_org_id) continue;
let org_info = await org(org_id as number, api_token);
if (typeof org_info === "string") {
logger.error(org_info);
fullPageError = true;
fullPageErrorTitle = t('admin.apierror.loadunownedorg');
fullPageErrorSubtitle = t('admin.apierror.' + org_info);
return;
}
orgs[org_info.name] = org_info;
}
logger.info("Loaded " + org_list.length + " associated orgs, finish displayDataLoad");
}) })
function setOrg(to) {
window.location.href = "/org/" + to;
}
function newOrg() {
window.location.href = "/org/new";
}
function joinOrg() {
window.location.href = "/org/join";
}
</script> </script>
{#if fullPageError}
<FullPageError title="{fullPageErrorTitle}" subtitle="{fullPageErrorSubtitle}"/>
{:else}
<div class="flex in-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8 h-max">
<div class="w-full max-w-md m-auto">
<!-- org selector -->
<h1 class="font-semibold text-xl">Select an organization to manage</h1>
<div class="bg-slate-200 dark:bg-slate-700 rounded p-2 mt-2">
<div class="grid grid-cols-1 divide-width divide-y divide-black dark:divide-gray-100">
<div>
{#if owns_org}
<div class="cursor-pointer flow-root transition hover:bg-slate-300 dark:hover:bg-slate-600 rounded m-2 p-2 group"
on:click={() => {setOrg(owned_org_id)}}>
<span class="float-left cursor-pointer text-md">Your organization</span>
<span class="float-right cursor-pointer text-right text-md font-light text-slate-200 dark:text-slate-700 group-hover:text-slate-200">Next &rarr;</span>
</div>
{:else}
<div class="cursor-pointer flow-root transition hover:bg-slate-300 dark:hover:bg-slate-600 rounded m-2 p-2 group"
on:click={() => {newOrg()}}>
<span class="float-left cursor-pointer text-md">+ Create an organization</span>
</div>
{/if}
</div>
<!--
<div>
<h4 class="ml-0.5 text-xs font-bold text-gray-200 dark:text-gray-400 mt-0.5">Organizations you have access to</h4>
<div class="cursor-pointer flow-root transition hover:bg-slate-300 dark:hover:bg-slate-600 rounded m-2 p-2 group" on:click={() => {setOrg("1")}}>
<span class="float-left cursor-pointer text-md">core@e3t.cc's organization</span>
<span class="float-right cursor-pointer text-right text-md font-light text-slate-200 dark:text-slate-700 group-hover:text-slate-200">Next &rarr;</span>
</div>
<div class="cursor-pointer flow-root transition hover:bg-slate-300 dark:hover:bg-slate-600 rounded m-2 p-2 group" on:click={() => {setOrg("1")}}>
<span class="float-left cursor-pointer text-md">core@coredoes.dev's organization</span>
<span class="float-right cursor-pointer text-right text-md font-light text-slate-200 dark:text-slate-700 group-hover:text-slate-200">Next &rarr;</span>
</div>
</div>
-->
<div>
<div class="cursor-pointer flow-root transition hover:bg-slate-300 dark:hover:bg-slate-600 rounded m-2 p-2 group"
on:click={() => {joinOrg()}}>
<span class="float-left cursor-pointer text-md">+ Join an existing organization</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}

View File

@ -73,8 +73,8 @@
<!-- The actual form --> <!-- The actual form -->
<form class="mt-5" action="#" method="POST" on:submit|preventDefault={generateMagicLink}> <form class="mt-5" action="#" method="POST" on:submit|preventDefault={generateMagicLink}>
<div class="-space-y-px rounded-md shadow-sm"> <div class="rounded-md shadow-sm">
<label for="email" class="sr-only">{t('login.prompt')}</label> <label for="email">{t('login.prompt')}</label>
<input bind:value={email} id="email" <input bind:value={email} id="email"
class="dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none"> class="dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none">
{#if hasError} {#if hasError}

View File

@ -0,0 +1,150 @@
<script lang="ts">
import {onMount} from "svelte";
import {enforce_auth, enforce_session} from "$lib/auth";
import {Logger, logSetup} from "$lib/logger";
import {list_org} from "$lib/orgs";
import {t} from "$lib/i18n";
import FullPageError from "../../../components/FullPageError.svelte";
import {get_user_info} from "$lib/auth";
import {org} from "$lib/orgs";
import type {Organization} from "$lib/orgs";
let logger = new Logger("admin/+page.svelte");
logSetup();
let api_token = "";
let fullPageError = false;
let fullPageErrorTitle = "";
let fullPageErrorSubtitle = "";
let user_info;
let org_ip_range = "172.16.0.0/24";
let org_subnets = "";
let org_groups = "";
let hasError_iprange = false;
let error_iprange = "";
let hasError_subnet = false;
let error_subnet = "";
let isloading = false;
let canSubmit = true;
function recalcCansubmit() {
canSubmit = hasError_iprange || fullPageError || hasError_subnet;
}
// this page requires session and mfa auth.
onMount(async () => {
let st_result = await enforce_session();
if (!st_result[0]) {
// Session token is invalid. redirect to login
window.location = "/auth/login";
return;
}
let at_result = await enforce_auth();
if (!at_result[0]) {
// Auth token is invalid. Redirect to mfa page.
window.location = "/auth/mfa";
return;
}
// user is fully authenticated and permitted to proceed
api_token = at_result[1];
logger.info("User authenticated successfully");
// GET USER INFO
user_info = await get_user_info(api_token);
if (typeof user_info === "string") {
logger.error(user_info);
fullPageError = true;
fullPageErrorTitle = t('admin.apierror.loaduser');
fullPageErrorSubtitle = t('admin.apierror.' + user_info);
return;
}
let owned_org_id = user_info.data.actor.organizationID;
if (owned_org_id !== "") {
logger.error("User owns an org already");
window.location.href = "/admin";
}
})
let showAdditionalConstraints = false;
function newOrg() {
}
</script>
{#if fullPageError}
<FullPageError title="{fullPageErrorTitle}" subtitle="{fullPageErrorSubtitle}"/>
{:else}
<div class="flex in-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8 h-max">
<div class="w-full max-w-md m-auto">
<div>
<h1 class="font-semibold text-2xl">{t('neworg.title')}</h1>
<h2 class="text-sm">{t('neworg.subtitle')}</h2>
<h3 class="font-semibold mt-5">{t('neworg.firstcert')}</h3>
<h3 class="text-sm">{t('neworg.cahelp')}</h3>
</div>
<form class="mt-5" action="#" method="POST" on:submit|preventDefault={newOrg}>
<div class=" rounded-md shadow-sm">
<label for="iprange" class="mb-5">{t('neworg.iprangeprompt')}</label>
<input bind:value={org_ip_range} id="iprange"
class="mt-2 dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none">
<span class="text-sm block">{t('neworg.cidrhelp')}</span>
{#if hasError_iprange}
<span class="text-red-600 text-sm block">{error_iprange}</span>
{/if}
</div>
<label class="relative inline-flex items-center cursor-pointer mt-4 ">
<input bind:checked={showAdditionalConstraints} type="checkbox" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{t('neworg.additionalConstraints')}</span>
</label>
{#if showAdditionalConstraints}
<div class=" rounded-md shadow-sm">
<label for="subnets" class="mb-5">{t('neworg.subnetprompt')}</label>
<input bind:value={org_subnets} id="subnets"
class="mt-2 dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none">
<span class="text-sm block">{t('neworg.subnethelp')}</span>
{#if hasError_subnet}
<span class="text-red-600 text-sm block">{error_subnet}</span>
{/if}
</div>
<div class="mt-2 rounded-md shadow-sm">
<label for="groups" class="mb-5">{t('neworg.groupprompt')}</label>
<input bind:value={org_groups} id="groups"
class="mt-2 dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none">
<span class="text-sm block">{t('neworg.grouphelp')}</span>
{#if hasError_subnet}
<span class="text-red-600 text-sm block">{error_subnet}</span>
{/if}
</div>
{/if}
<button disabled="{!canSubmit}"
class="bg-purple-400 dark:bg-purple-600 mt-4 w-full py-2 -space-y-px rounded-md shadow-sm place-content-center">
{#if !isloading}
{t('neworg.actionButtonText')}
{:else}
<svg class="animate-spin w-5 h-5 inline-block m-auto" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
</button>
</form>
</div>
</div>
{/if}

View File

@ -62,8 +62,8 @@
<!-- The actual form --> <!-- The actual form -->
<form class="mt-5" action="#" method="POST" on:submit|preventDefault={createAcc}> <form class="mt-5" action="#" method="POST" on:submit|preventDefault={createAcc}>
<div class="-space-y-px rounded-md shadow-sm"> <div class="rounded-md shadow-sm">
<label for="email" class="sr-only">{t('signup.prompt')}</label> <label for="email">{t('signup.prompt')}</label>
<input bind:value={email} id="email" <input bind:value={email} id="email"
class="dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none"> class="dark:bg-slate-500 bg-gray-200 w-full rounded px-3 py-2 focus:outline-none focus:ring-purple-500 appearance-none">
{#if hasError} {#if hasError}

View File

@ -178,7 +178,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
crate::routes::v1::organization::orgidoptions, crate::routes::v1::organization::orgidoptions,
crate::routes::v1::organization::orginfo_req, crate::routes::v1::organization::orginfo_req,
crate::routes::v1::organization::orglist_req, crate::routes::v1::organization::orglist_req,
crate::routes::v1::organization::create_org crate::routes::v1::organization::create_org,
crate::routes::v1::user::get_user,
crate::routes::v1::user::options
]) ])
.register("/", catchers![ .register("/", catchers![
crate::routes::handler_400, crate::routes::handler_400,

View File

@ -20,3 +20,4 @@ pub mod signup;
pub mod totp_authenticators; pub mod totp_authenticators;
pub mod verify_totp_authenticator; pub mod verify_totp_authenticator;
pub mod organization; pub mod organization;
pub mod user;

View File

@ -0,0 +1,47 @@
// trifid-api, an open source reimplementation of the Defined Networking nebula management server.
// Copyright (C) 2023 c0repwn3r
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use serde::{Serialize, Deserialize};
use rocket::{get, options};
use rocket::http::{ContentType, Status};
use rocket::serde::json::Json;
use rocket::State;
use sqlx::PgPool;
#[options("/v1/user/<_id>")]
pub fn options(_id: i32) -> &'static str {
""
}
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct UserInfoResponse {
email: String
}
#[get("/v1/user/<id>")]
pub async fn get_user(id: i32, db: &State<PgPool>) -> Result<(ContentType, Json<UserInfoResponse>), (Status, String)> {
let user = match sqlx::query!("SELECT email FROM users WHERE id = $1", id.clone() as i32).fetch_one(db.inner()).await {
Ok(u) => u,
Err(e) => return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_QL_QUERY_FAILED", "an error occurred while running the graphql query", e)))
};
Ok((ContentType::JSON, Json(
UserInfoResponse {
email: user.email,
}
)))
}

View File

@ -21,6 +21,7 @@ use rocket::http::{ContentType, Status};
use rocket::serde::json::Json; use rocket::serde::json::Json;
use sqlx::PgPool; use sqlx::PgPool;
use crate::auth::PartialUserInfo; use crate::auth::PartialUserInfo;
use crate::org::get_org_by_owner_id;
use crate::tokens::user_has_totp; use crate::tokens::user_has_totp;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -58,12 +59,19 @@ pub fn options() -> &'static str {
#[get("/v2/whoami")] #[get("/v2/whoami")]
pub async fn whoami_request(user: PartialUserInfo, db: &State<PgPool>) -> Result<(ContentType, Json<WhoamiResponse>), (Status, String)> { pub async fn whoami_request(user: PartialUserInfo, db: &State<PgPool>) -> Result<(ContentType, Json<WhoamiResponse>), (Status, String)> {
let org = match get_org_by_owner_id(user.user_id, db.inner()).await {
Ok(b) => match b {
Some(r) => r.to_string(),
None => String::new()
},
Err(e) => return Err((Status::InternalServerError, format!("{{\"errors\":[{{\"code\":\"{}\",\"message\":\"{} - {}\"}}]}}", "ERR_DBERROR", "an error occured trying to verify your user", e)))
};
Ok((ContentType::JSON, Json(WhoamiResponse { Ok((ContentType::JSON, Json(WhoamiResponse {
data: WhoamiData { data: WhoamiData {
actor_type: "user".to_string(), actor_type: "user".to_string(),
actor: WhoamiActor { actor: WhoamiActor {
id: user.user_id.to_string(), id: user.user_id.to_string(),
organization_id: "TEMP_ORG_BECAUSE_THAT_ISNT_IMPLEMENTED_YET".to_string(), organization_id: org,
email: user.email, email: user.email,
created_at: NaiveDateTime::from_timestamp_opt(user.created_at, 0).unwrap().and_local_timezone(Utc).unwrap().to_rfc3339(), created_at: NaiveDateTime::from_timestamp_opt(user.created_at, 0).unwrap().and_local_timezone(Utc).unwrap().to_rfc3339(),
has_totpauthenticator: match user_has_totp(user.user_id, db.inner()).await { has_totpauthenticator: match user_has_totp(user.user_id, db.inner()).await {