({ defaultValues: defaultValue });
+ const { tenant } = useParams();
+ const isWasm = defaultValue && "wasmConfig" in defaultValue;
+ const [type, setType] = useState(isWasm ? "Existing WASM script" : "Classic");
+ React.useEffect(() => {
+ if (type === "Existing WASM script") {
+ setValue("wasmConfig.source.kind", "Local");
+ }
+ }, [type]);
+ const {
+ register,
+ handleSubmit,
+ control,
+ formState: { errors },
+ setValue,
+ } = methods;
+
+ const projectQuery = useQuery(projectQueryKey(tenant!, project), () =>
+ queryProject(tenant!, project)
+ );
+
+ if (projectQuery.isError) {
+ return Error while fetching project
;
+ } else if (projectQuery.data) {
+ return (
+
+
+
+ );
+ } else {
+ return Loading...
;
+ }
+}
diff --git a/izanami-frontend/src/components/RightSelector.tsx b/izanami-frontend/src/components/RightSelector.tsx
new file mode 100644
index 000000000..f24ec4c2e
--- /dev/null
+++ b/izanami-frontend/src/components/RightSelector.tsx
@@ -0,0 +1,638 @@
+import * as React from "react";
+import { useContext, useState } from "react";
+import { useQuery } from "react-query";
+import Select from "react-select";
+import {
+ findKeyRight,
+ findProjectRight,
+ findTenantRight,
+ rightsBelow,
+ IzanamiContext,
+} from "../securityContext";
+import { customStyles } from "../styles/reactSelect";
+import {
+ MutationNames,
+ queryKeys,
+ queryTenant,
+ queryTenants,
+ tenantKeyQueryKey,
+ tenantQueryKey,
+} from "../utils/queries";
+import { TLevel, TRights } from "../utils/types";
+
+const EventType = {
+ SelectTenant: "SelectTenant",
+ DeleteTenant: "DeleteTenant",
+ SelectProject: "SelectProject",
+ DeleteProject: "DeleteProject",
+ SetTenantLevel: "SetTenantLevel",
+ SetProjectLevel: "SetProjectLevel",
+ SelectKey: "SelectKey",
+ DeleteKey: "DeleteKey",
+ SetKeyLevel: "SetKeyLevel",
+ SetupState: "SetupState",
+} as const;
+
+type EventType = typeof EventType[keyof typeof EventType];
+
+interface Event {
+ type: EventType;
+}
+
+interface ProjectSelectionEvent extends Event {
+ type: "SelectProject";
+ name: string;
+ tenant: string;
+}
+
+interface TenantSelectionEvent extends Event {
+ type: "SelectTenant";
+ name: string;
+}
+
+interface ProjectDeleteEvent extends Event {
+ type: "DeleteProject";
+ name: string;
+ tenant: string;
+}
+
+interface TenantDeleteEvent extends Event {
+ type: "DeleteTenant";
+ name: string;
+}
+
+interface TenantLevelEvent extends Event {
+ type: "SetTenantLevel";
+ level: TLevel;
+ name: string;
+}
+
+interface ProjectLevelEvent extends Event {
+ type: "SetProjectLevel";
+ level: TLevel;
+ tenant: string;
+ name: string;
+}
+
+interface KeySelectionEvent extends Event {
+ type: "SelectKey";
+ name: string;
+ tenant: string;
+}
+
+interface KeyDeleteEvent extends Event {
+ type: "DeleteKey";
+ name: string;
+ tenant: string;
+}
+
+interface KeyLevelEvent extends Event {
+ type: "SetKeyLevel";
+ level: TLevel;
+ tenant: string;
+ name: string;
+}
+
+interface InitialSetup extends Event {
+ type: "SetupState";
+ rights: TRights;
+}
+
+type EventTypes =
+ | ProjectSelectionEvent
+ | TenantSelectionEvent
+ | ProjectDeleteEvent
+ | TenantDeleteEvent
+ | TenantLevelEvent
+ | ProjectLevelEvent
+ | KeySelectionEvent
+ | KeyDeleteEvent
+ | KeyLevelEvent
+ | InitialSetup;
+
+type State = {
+ name: string;
+ level?: TLevel;
+ projects: {
+ name: string;
+ level?: TLevel;
+ }[];
+ keys: {
+ name: string;
+ level?: TLevel;
+ }[];
+}[];
+
+function projectOrKeyArrayToObject(arr: { name: string; level?: TLevel }[]) {
+ return arr.reduce((acc, { name, level }) => {
+ acc[name] = { level };
+ return acc;
+ }, {} as { [x: string]: any });
+}
+
+export function rightStateArrayToBackendMap(state: State): TRights {
+ if (!state) {
+ return { tenants: {} };
+ }
+ const backendRights = state.reduce((acc, { name, level, projects, keys }) => {
+ acc[name] = {
+ level,
+ projects: projectOrKeyArrayToObject(projects),
+ keys: projectOrKeyArrayToObject(keys),
+ };
+ return acc;
+ }, {} as { [x: string]: any });
+
+ return { tenants: backendRights };
+}
+
+const reducer = function reducer(state: State, event: EventTypes): State {
+ switch (event.type) {
+ case EventType.SetupState: {
+ if (!event.rights.tenants) {
+ return state;
+ } else {
+ return Object.entries(event.rights.tenants).map(([key, value]) => ({
+ name: key,
+ level: value.level,
+ projects: Object.entries(value.projects).map(
+ ([projectName, projectValue]) => ({
+ name: projectName,
+ level: projectValue.level,
+ })
+ ),
+ keys: Object.entries(value.keys).map(([keyName, keyValue]) => ({
+ name: keyName,
+ level: keyValue.level,
+ })),
+ }));
+ }
+ }
+ case EventType.DeleteProject:
+ return [...state].map((el) => {
+ if (el.name === event.tenant) {
+ const projects = el.projects;
+
+ return {
+ ...el,
+ projects: [...projects].filter(({ name }) => name !== event.name),
+ };
+ }
+ return el;
+ });
+ case EventType.DeleteTenant:
+ return [...state].filter(({ name }) => name !== event.name);
+ case EventType.SelectProject:
+ return [...state].map((el) => {
+ if (el.name === event.tenant) {
+ const projects = el.projects;
+
+ return {
+ ...el,
+ projects: [...projects, { name: event.name, level: TLevel.Read }],
+ };
+ }
+ return el;
+ });
+ case EventType.SelectTenant:
+ return [
+ ...state,
+ { name: event.name, level: TLevel.Read, projects: [], keys: [] },
+ ];
+ case EventType.SetTenantLevel:
+ return [...state].map((el) => {
+ if (el.name === event.name) {
+ return { ...el, level: event.level };
+ }
+ return el;
+ });
+ case EventType.SetProjectLevel:
+ return [...state].map((el) => {
+ if (el.name === event.tenant) {
+ return {
+ ...el,
+ projects: el.projects.map((p) => {
+ if (p.name === event.name) {
+ return { ...p, level: event.level };
+ }
+ return p;
+ }),
+ };
+ }
+ return el;
+ });
+ case EventType.SetKeyLevel:
+ return [...state].map((el) => {
+ if (el.name === event.tenant) {
+ return {
+ ...el,
+ keys: el.keys.map((k) => {
+ if (k.name === event.name) {
+ return { ...k, level: event.level };
+ }
+ return k;
+ }),
+ };
+ }
+ return el;
+ });
+ case EventType.DeleteKey:
+ return [...state].map((el) => {
+ if (el.name === event.tenant) {
+ const keys = el.keys;
+
+ return {
+ ...el,
+ keys: [...keys].filter(({ name }) => name !== event.name),
+ };
+ }
+ return el;
+ });
+ case EventType.SelectKey:
+ return [...state].map((el) => {
+ if (el.name === event.tenant) {
+ const keys = el.keys;
+
+ return {
+ ...el,
+ keys: [...keys, { name: event.name, level: TLevel.Read }],
+ };
+ }
+ return el;
+ });
+ }
+};
+
+export function isValid(state: State): boolean {
+ return state.every(({ name, level, projects, keys }) => {
+ return (
+ name &&
+ level &&
+ projects.every(({ name, level }) => name && level) &&
+ keys.every(({ name, level }) => name && level)
+ );
+ });
+}
+
+export function RightSelector(props: {
+ defaultValue?: TRights;
+ tenantLevelFilter?: TLevel;
+ tenant?: string;
+ onChange: (value: any) => void;
+}) {
+ const { defaultValue, tenantLevelFilter, onChange } = props;
+ const tenantQuery = useQuery(MutationNames.TENANTS, () =>
+ queryTenants(tenantLevelFilter)
+ );
+
+ const [state, dispatch] = React.useReducer(reducer, []);
+ React.useEffect(() => {
+ if (defaultValue) {
+ dispatch({ type: EventType.SetupState, rights: defaultValue });
+ }
+ }, []);
+ React.useEffect(() => {
+ if (isValid(state)) {
+ onChange(state);
+ }
+ }, [state]);
+
+ const selectedTenants = state.map(({ name }) => name);
+ const [creating, setCreating] = useState(
+ selectedTenants.length === 0 && !defaultValue
+ );
+
+ const { user } = useContext(IzanamiContext);
+ const { admin, rights } = user!;
+
+ if (tenantQuery.isLoading) {
+ return Loading tenants...
;
+ } else if (tenantQuery.data) {
+ const tenants = tenantQuery.data.map((t) => t.name);
+ const selectorChoices = tenants.filter((item) => {
+ return !selectedTenants.includes(item);
+ });
+ return (
+
+ <>
+ {state.map(({ name, level, projects, keys }) => {
+ return (
+ <>
+
+
+
{
+ dispatch({ type: EventType.DeleteTenant, name });
+ dispatch({ type: EventType.SelectTenant, name: item });
+ }}
+ onLevelChange={(level) => {
+ dispatch({ type: EventType.SetTenantLevel, level, name });
+ }}
+ onClear={() => {
+ dispatch({ type: EventType.DeleteTenant, name });
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ })}
+ {creating && (
+ <>
+
+
{
+ setCreating(false);
+ dispatch({ type: EventType.SelectTenant, name: item });
+ }}
+ />
+ >
+ )}
+ >
+ {!props.tenant &&
+ !creating &&
+ selectedTenants.length < tenantQuery.data.length && (
+
+ )}
+
+ );
+ } else {
+ // TODO
+ return Failed to load tenants
;
+ }
+}
+
+function ProjectSelector(props: {
+ tenant: string;
+ dispatch: (event: EventTypes) => void;
+ projects: { name: string; level?: TLevel }[];
+}) {
+ const [creating, setCreating] = useState(false);
+ const { tenant, dispatch, projects } = props;
+
+ const { user } = useContext(IzanamiContext);
+ const { admin, rights } = user!;
+
+ const projectQuery = useQuery(tenantQueryKey(tenant), () =>
+ queryTenant(tenant)
+ );
+
+ if (projectQuery.isLoading) {
+ return Loading...
;
+ } else if (projectQuery.data?.projects?.length === 0) {
+ return (
+
+ No project defined yet
+
+ );
+ } else if (projectQuery.data) {
+ const selectedProjectNames = projects.map((p) => p.name);
+ const availableProjects = (
+ projectQuery.data.projects?.map((p) => p.name) || []
+ ).filter((p) => !selectedProjectNames.includes(p));
+
+ return (
+ <>
+ {projects.map(({ name, level }) => (
+
+ {
+ dispatch({ type: "DeleteProject", name, tenant });
+ dispatch({ type: "SelectProject", name: item, tenant });
+ }}
+ level={level}
+ onLevelChange={(level) => {
+ dispatch({
+ type: "SetProjectLevel",
+ name,
+ tenant,
+ level,
+ });
+ }}
+ name={name}
+ onClear={() => {
+ dispatch({ type: "DeleteProject", tenant, name });
+ }}
+ />
+
+ ))}
+ {creating && (
+
+ {
+ setCreating(false);
+ dispatch({ type: "SelectProject", name: project, tenant });
+ }}
+ onClear={() => setCreating(false)}
+ />
+
+ )}
+ {!creating &&
+ projects.length < (projectQuery.data.projects?.length || 0) && (
+
+ )}
+ >
+ );
+ } else {
+ return Error while loading projects
;
+ }
+}
+
+function KeySelector(props: {
+ tenant: string;
+ dispatch: (event: EventTypes) => void;
+ keys: { name: string; level?: TLevel }[];
+}) {
+ const [creating, setCreating] = useState(false);
+ const { tenant, dispatch, keys } = props;
+
+ const keyQuery = useQuery(tenantKeyQueryKey(tenant), () => queryKeys(tenant));
+ const { user } = useContext(IzanamiContext);
+ const { admin, rights } = user!;
+
+ if (keyQuery.isLoading) {
+ return Loading...
;
+ } else if (keyQuery.data?.length === 0) {
+ return (
+
+ No keys defined yet
+
+ );
+ } else if (keyQuery.data) {
+ const selectedKeyNames = keys.map(({ name }) => name);
+ const availableKeys = (keyQuery.data.map((k) => k.name) || []).filter(
+ (p) => !selectedKeyNames.includes(p)
+ );
+ return (
+ <>
+ {keys.map(({ name, level }) => (
+
+ {
+ dispatch({ type: "DeleteKey", name, tenant });
+ dispatch({ type: "SelectKey", name: item, tenant });
+ }}
+ level={level}
+ userRight={
+ admin || findTenantRight(rights, tenant) === TLevel.Admin
+ ? TLevel.Admin
+ : findKeyRight(rights, tenant, name) || TLevel.Read
+ }
+ onLevelChange={(level) => {
+ dispatch({
+ type: "SetKeyLevel",
+ name,
+ tenant,
+ level,
+ });
+ }}
+ name={name}
+ onClear={() => {
+ dispatch({ type: "DeleteKey", tenant, name });
+ }}
+ />
+
+ ))}
+ {creating && (
+
+ {
+ setCreating(false);
+ dispatch({ type: "SelectKey", name: project, tenant });
+ }}
+ onClear={() => setCreating(false)}
+ userRight={TLevel.Read}
+ />
+
+ )}
+ {!creating && keys.length < (keyQuery.data.length || 0) && (
+
+ )}
+ >
+ );
+ } else {
+ return Error while loading projects
;
+ }
+}
+
+function ItemSelector(props: {
+ choices: string[];
+ userRight?: TLevel;
+ onItemChange: (name: string) => void;
+ onLevelChange?: (level: TLevel) => void;
+ onClear?: () => void;
+ level?: TLevel;
+ name?: string;
+ rightOnly?: boolean;
+ label: string;
+}) {
+ const {
+ choices,
+ onItemChange,
+ onLevelChange,
+ onClear,
+ name,
+ level,
+ userRight,
+ label,
+ } = props;
+
+ const baseAriaLabel = `${label}${name ? ` ${name}` : ""}`;
+
+ return (
+
+
+ );
+}
diff --git a/izanami-frontend/src/components/TimeZoneSelect.tsx b/izanami-frontend/src/components/TimeZoneSelect.tsx
new file mode 100644
index 000000000..7eb9556f8
--- /dev/null
+++ b/izanami-frontend/src/components/TimeZoneSelect.tsx
@@ -0,0 +1,33 @@
+import * as React from "react";
+import Select from "react-select";
+import { customStyles } from "../styles/reactSelect";
+
+export const DEFAULT_TIMEZONE =
+ Intl.DateTimeFormat().resolvedOptions().timeZone;
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+const possibleTimezones: string[] = Intl.supportedValuesOf("timeZone");
+
+export function TimeZoneSelect(props: {
+ onChange: (newValue: string) => void;
+ value: string;
+}) {
+ const { value, onChange } = props;
+ return (
+ {
+ onChange(e?.value as any);
+ }}
+ options={possibleTimezones.map((t) => ({
+ label: t,
+ value: t,
+ }))}
+ styles={customStyles}
+ />
+ );
+}
diff --git a/izanami-frontend/src/components/Tooltip.tsx b/izanami-frontend/src/components/Tooltip.tsx
new file mode 100644
index 000000000..dde6d95d1
--- /dev/null
+++ b/izanami-frontend/src/components/Tooltip.tsx
@@ -0,0 +1,20 @@
+import React, { PropsWithChildren } from "react";
+import { Tooltip as ReactTooltip } from "react-tooltip";
+
+export function Tooltip(
+ props: PropsWithChildren<{
+ position?: "bottom" | "top" | "right" | "left";
+ id: string;
+ }>
+) {
+ return (
+ <>
+
+ {props.children}
+ >
+ );
+}
diff --git a/izanami-frontend/src/components/WasmInput.tsx b/izanami-frontend/src/components/WasmInput.tsx
new file mode 100644
index 000000000..18fe32ce3
--- /dev/null
+++ b/izanami-frontend/src/components/WasmInput.tsx
@@ -0,0 +1,436 @@
+import * as React from "react";
+import { customStyles } from "../styles/reactSelect";
+import Select from "react-select";
+import { useQuery } from "react-query";
+import { Controller, useFormContext, useWatch } from "react-hook-form";
+import { TContextOverload } from "../utils/types";
+import { useParams } from "react-router-dom";
+import { ErrorDisplay } from "./FeatureForm";
+import { FEATURE_NAME_REGEXP } from "../utils/patterns";
+import { IzanamiContext } from "../securityContext";
+import { Tooltip } from "./Tooltip";
+
+export function WasmInput() {
+ const {
+ register,
+ formState: { errors },
+ } = useFormContext();
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+function KindOptions() {
+ const {
+ register,
+ control,
+ getValues,
+ setValue,
+ formState: { errors },
+ } = useFormContext();
+ const query = useQuery("WASMSCRIPTS", () => loadWasmManagerScripts());
+ const { integrations } = React.useContext(IzanamiContext);
+
+ let opts = <>>;
+
+ useWatch({ name: "wasmConfig.source.kind" });
+ switch (getValues("wasmConfig.source.kind")) {
+ case "Wasmo":
+ if (query.error) {
+ opts = Failed to load wasm scripts
;
+ } else if (query.data) {
+ opts = (
+ <>
+
+
+ >
+ );
+ } else {
+ opts = Loading...
;
+ }
+ break;
+ case "Http":
+ opts = (
+ <>
+
+
+
+
+
+ >
+ );
+ break;
+ case "File":
+ opts = (
+
+ );
+ break;
+ case "Base64":
+ opts = (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {opts}
+ >
+ );
+}
+
+function loadWasmManagerScripts() {
+ return fetch("/api/admin/plugins", {
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ })
+ .then((r) => r.json())
+ .then((plugins) => {
+ return (
+ plugins
+ .map(
+ (plugin: { versions: object[]; pluginId: string }) =>
+ plugin.versions?.map(
+ (version) => ({ ...version, id: plugin.pluginId } || [])
+ ) || []
+ )
+ .flat()
+ // FIXME TS
+ .map((plugin: any) => {
+ const wasmName = (isAString(plugin) ? plugin : plugin.name) || "";
+ const parts = wasmName.split(".wasm");
+ return {
+ label: `${parts[0]} - ${
+ parts[0].endsWith("-dev") ? "[DEV]" : "[RELEASE]"
+ }`,
+ value: wasmName,
+ };
+ })
+ );
+ })
+ .then((foo) => {
+ return foo;
+ });
+}
+
+function isAString(variable: any) {
+ return typeof variable === "string" || variable instanceof String;
+}
+
+function ObjectInput(props: {
+ value: [string, string][];
+ onChange: (value: [string, string][]) => void;
+}) {
+ const { value: fieldValue, onChange } = props;
+ return (
+ <>
+ {fieldValue.length > 0 && (
+
+
+
+ Key |
+ Value |
+
+
+
+ {(fieldValue ?? []).map(([key, value], index) => {
+ return (
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+ );
+ })}
+
+
+ )}
+
+ >
+ );
+}
+
+export function ExistingScript() {
+ const { tenant } = useParams();
+ const {
+ control,
+ setValue,
+ formState: { errors },
+ } = useFormContext();
+
+ const query = useQuery("LOCAL_SCRIPTS", () =>
+ fetch(`/api/admin/tenants/${tenant}/local-scripts`)
+ .then((resp) => resp.json())
+ .then((ws) =>
+ ws.map(({ name }: { name: string }) => ({ label: name, value: name }))
+ )
+ );
+
+ if (query.error) {
+ return Failed to fetch local scripts
;
+ } else if (query.data) {
+ return (
+ <>
+
+
+ >
+ );
+ } else {
+ return Loading local scripts ...
;
+ }
+}
diff --git a/izanami-frontend/src/index.scss b/izanami-frontend/src/index.scss
new file mode 100644
index 000000000..49d685ffa
--- /dev/null
+++ b/izanami-frontend/src/index.scss
@@ -0,0 +1,281 @@
+h1,
+h2 {
+ color: $colorGris;
+}
+
+.navbar {
+ .nav-link {
+ color: $primaryColor;
+ text-decoration: none;
+ }
+
+ .nav-link i {
+ margin-left: 0.5em;
+ }
+}
+
+main a:link,
+main a,
+main a:visited {
+ color: #fff;
+}
+
+main a:active,
+main a:hover,
+main a:focus {
+ color: $primaryColor;
+ & svg .icon_context {
+ fill: $primaryColor !important;
+ }
+}
+
+.breadcrumb .active a {
+ color: $colorGris;
+}
+.breadcrumb svg .icon_context {
+ fill: $colorGris !important;
+}
+
+.breadcrumb .breadcrumb-item a {
+ &:hover,
+ &:active,
+ &:focus {
+ color: $primaryColor;
+ }
+}
+
+#root table td {
+ color: #b5b3b3;
+}
+
+#root table th {
+ color: #fff;
+}
+
+#root .modal {
+ width: 100%;
+}
+
+.breadcrumb a {
+ color: $primaryColor;
+ text-decoration: none;
+
+ &:hover {
+ color: #fff;
+ }
+}
+
+// h1 {
+// font-size: 2rem;
+// }
+
+input,
+textarea {
+ color: $colorGris !important;
+ border-color: $fondBody !important;
+ border-radius: 4px;
+ border: none;
+}
+
+input:not([role="combobox"]):not([type="checkbox"]),
+textarea {
+ /* buggy on react-select */
+ background-color: $fondNavbar;
+}
+
+.card {
+ background-color: #494948;
+}
+
+#root .mrf-content_switch_button_on {
+ background-color: $primaryColor;
+}
+
+#root .mrf-content_switch_button_off {
+ background-color: $colorGris;
+}
+
+.MuiOutlinedInput-notchedOutline {
+ top: -7px;
+ border-color: #494948 !important;
+}
+
+.MuiSvgIcon-root {
+ color: #fff;
+}
+
+.alert {
+ background-color: $primaryColor;
+ color: #fff;
+ border-color: #fff;
+}
+
+thead th {
+ & > div {
+ min-height: 62px;
+ }
+
+ & input.table-filter {
+ height: 38px;
+ }
+}
+
+.mrf-btn {
+ background-color: transparent;
+ background: none;
+ margin-right: 5px;
+}
+.mrf-btn.mrf-btn_blue {
+ color: #fff;
+ border: 1px solid;
+ border-color: $colorBleue;
+ &:hover,
+ &.active,
+ &.active:hover {
+ color: #fff;
+ border: 1px solid;
+ border-color: $colorBleue;
+ background-color: $colorBleue;
+ }
+}
+
+.mrf-btn.mrf-btn_red {
+ color: #fff;
+ border: 1px solid;
+ border-color: $colorRouge;
+ &:hover {
+ color: #fff;
+ border: 1px solid;
+ border-color: $colorRouge;
+ background-color: $colorRouge;
+ }
+}
+
+.right-selector {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+}
+
+.right-selector .right-name-selector {
+ flex-grow: 1;
+ max-width: 70%;
+}
+
+.project-selector,
+.key-selector {
+ margin-top: 24px;
+ margin-bottom: 24px;
+ background-color: #49494873;
+ border-radius: 10px;
+ padding: 10px;
+ margin-left: 30px;
+}
+
+.dropdown-toggle::after {
+ display: none;
+}
+
+.izanami-tree-node {
+ margin-top: 10px;
+}
+
+.izanami-tree-node.open {
+ position: relative;
+}
+
+.izanami-tree-node.open:after {
+ border-left: 1px solid grey;
+ content: "";
+ position: absolute;
+ left: 9px;
+ bottom: 0;
+ top: 24px;
+}
+
+.activation-status {
+ font-weight: bold;
+}
+
+.enabled-status {
+ color: $primaryColor;
+}
+
+.current-context-overload {
+ color: $primaryColor;
+ font-weight: bold;
+}
+
+.izanami-checkbox {
+ cursor: pointer;
+ appearance: none;
+ margin-top: 10px;
+}
+
+.izanami-checkbox:checked {
+ width: 35px;
+ height: 22px;
+ border-radius: 20px;
+ display: flex;
+ background-color: $primaryColor;
+ border: 1px solid $primaryColor;
+ justify-content: flex-end;
+}
+
+.izanami-checkbox.izanami-checkbox-inline:checked,
+.izanami-checkbox.izanami-checkbox-inline:not(:checked) {
+ display: inline-flex;
+}
+
+.izanami-checkbox:checked:before {
+ content: "";
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ background-color: #fff;
+ border-radius: 20px;
+ box-shadow: 1px 0px 5px 0px rgba(0, 0, 0, 0.3);
+}
+
+.izanami-checkbox:not(:checked) {
+ width: 35px;
+ height: 22px;
+ border-radius: 20px;
+ display: flex;
+ background-color: $colorGris;
+ border: 1px solid $colorGris;
+ justify-content: flex-start;
+}
+
+.izanami-checkbox:not(:checked)::before {
+ content: "";
+ background-color: $colorGris;
+ border-radius: 20px;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ box-shadow: 1px 0px 5px 0px rgba(0, 0, 0, 0.3);
+}
+
+.izanami-checkbox:disabled:checked:before,
+.izanami-checkbox:disabled:not(:checked)::before,
+.izanami-checkbox:disabled {
+ cursor: default !important;
+}
+
+fieldset {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ // border: 1px solid $colorGris;
+ padding: 4px;
+ margin: 4px;
+}
+
+input[type="time"] {
+ max-width: 120px;
+}
+
+.react-tooltip {
+ z-index: 1001;
+}
diff --git a/izanami-frontend/src/main.tsx b/izanami-frontend/src/main.tsx
new file mode 100644
index 000000000..b87ed32d6
--- /dev/null
+++ b/izanami-frontend/src/main.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { App } from "./App";
+import "@maif/react-forms/lib/index.css";
+//import "./custom.scss";
+import "./styles/main.scss";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/izanami-frontend/src/pages/forgottenPassword.tsx b/izanami-frontend/src/pages/forgottenPassword.tsx
new file mode 100644
index 000000000..ffb122408
--- /dev/null
+++ b/izanami-frontend/src/pages/forgottenPassword.tsx
@@ -0,0 +1,107 @@
+import * as React from "react";
+import { NavLink } from "react-router-dom";
+import Logo from "../../izanami.png";
+import { Form, constraints } from "@maif/react-forms";
+
+function resetPassword(email: string): Promise {
+ return fetch("/api/admin/password/_reset", {
+ method: "POST",
+ body: JSON.stringify({ email: email }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+}
+
+export function ForgottenPassword() {
+ const [pending, setPending] = React.useState(false);
+ const [finished, setFinished] = React.useState(false);
+ return finished ? (
+
+
+
+
![]({Logo})
+
+ If this email is associated with an account, a mail has been send
+ with password reset instruction.
+
+ "align-self-end"} to={"/login"}>
+ Get back to login
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
![]({Logo})
+
Reset password
+
+
+
+
+
+ );
+}
diff --git a/izanami-frontend/src/pages/formReset.tsx b/izanami-frontend/src/pages/formReset.tsx
new file mode 100644
index 000000000..5b6e5599a
--- /dev/null
+++ b/izanami-frontend/src/pages/formReset.tsx
@@ -0,0 +1,69 @@
+import * as React from "react";
+import { Form, type, format, constraints } from "@maif/react-forms";
+import { NavLink } from "react-router-dom";
+import { PASSWORD_REGEXP } from "../utils/patterns";
+
+const schema = {
+ password: {
+ type: type.string,
+ format: format.password,
+ label: "New password",
+ constraints: [
+ constraints.required("New passord is required"),
+ constraints.matches(
+ PASSWORD_REGEXP,
+ `Password must match regex ${PASSWORD_REGEXP.toString()}`
+ ),
+ ],
+ props: {
+ autoFocus: true,
+ },
+ },
+ repeat: {
+ type: type.string,
+ format: format.password,
+ label: "Repeat new password",
+ constraints: [
+ constraints.test(
+ "password-equality",
+ "Passwords must be identical",
+ (value, { parent: { password } }) => {
+ return password === value;
+ }
+ ),
+ ],
+ },
+};
+
+export function FormReset(props: { token: string }) {
+ const { token } = props;
+ const [done, setDone] = React.useState(false);
+ return done ? (
+ <>
+
+
+
+ Password has been successfully updated
+ "align-self-end"} to={"/login"}>
+ Get back to login
+
+ >
+ ) : (
+