GitFOSS
feat: add settings page, allow to add keys
+ 766
- 153
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1712535226331,
+  "_generatedAtUnix": 1712538021580,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "assets": {

...
@@ -16,7 +16,7 @@
   },
   "islands": {
     "AppRouter": {
-      "hash": "4bea9624c055922280782592f3d728874a47b1f2",
+      "hash": "e5a99796c0971b1a27a880f28601e8aab4b33f34",
       "pathSource": "./app/islands/AppRouter.tsx",
       "pathBundle": "./public/.islands/AppRouter.bundle.js",
       "pathSourceMap": "./public/.islands/AppRouter.bundle.js.map"

app/components/Card.styled.ts
@@ -7,6 +7,8 @@ export const Card = styled.div<WithThemeSchemeProp>`
   display: flex;
   flex-flow: column nowrap;
 
+  width: 100%;
+
   padding: 16px;
 
   ${({ themeScheme = "light" }) => css`

app/components/Layout.tsx
@@ -66,6 +66,9 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
             a > * {
               pointer-events: none;
             }
+            form {
+              width: 100%;
+            }
           `),
         }}
       />

app/controllers/index.ts
@@ -3,7 +3,8 @@ export { HomeController } from "./home";
 export { OrganizationController } from "./organization";
 export { RepositoryController } from "./repository";
 export { RepositoryPullRequestsController } from "./repositoryPullRequests";
+export { SettingsController } from "./settings";
+export { SSHAuthController } from "./ssh-auth";
 export { SyntaxHighlightController } from "./syntaxHighlight";
 export { ThemeController } from "./theme";
 export { UserController } from "./user";
-export { SSHAuthController } from "./ssh-auth";

new file
app/controllers/settings/getSettingsView.ts
@@ -0,0 +1,34 @@
+// 1st-party
+import { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRouteParams } from "../../routes.defs";
+import { makeUsersService } from "../../services/user";
+// app views
+import SettingsView, {
+  SettingsViewProps,
+} from "../../views/settings/SettingsView";
+
+const getSettingsView: ReqHandler<AppRouteParams, AppRoute.SETTINGS> = async (
+  request,
+  reply
+) => {
+  const { curr_user_username } = request.session.data;
+
+  if (curr_user_username == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const usersService = makeUsersService({ request });
+  const user = await usersService.getUserByUsername(curr_user_username);
+
+  if (user == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler<SettingsViewProps>(SettingsView.name, {
+    sshKeys: await usersService.getUserSSHKeys(user),
+  });
+};
+
+export default getSettingsView;

new file
app/controllers/settings/index.ts
@@ -0,0 +1,8 @@
+import { SettingsKeysController } from "./keys";
+
+import { default as getSettingsView } from "./getSettingsView";
+
+export const SettingsController = {
+  ...SettingsKeysController,
+  getSettingsView,
+};

new file
app/controllers/settings/keys/getKeyAddView.ts
@@ -0,0 +1,24 @@
+// 1st-party
+import { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRouteParams } from "app/routes.defs";
+// app views
+import SettingsKeyAddView from "../../../views/settings/SettingsKeyAddView";
+
+const getKeyAddView: ReqHandler<
+  AppRouteParams,
+  AppRoute.SETTINGS_KEY_ADD
+> = async (request, reply) => {
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler(SettingsKeyAddView.name, {
+    flash_message: request.session.data.flash_message,
+    flash_data: request.session.data.flash_data
+      ? {
+          name: request.session.data.flash_data?.keyName,
+          key: request.session.data.flash_data?.key,
+        }
+      : undefined,
+  });
+};
+
+export default getKeyAddView;

new file
app/controllers/settings/keys/getKeyUpdateView.ts
@@ -0,0 +1,16 @@
+// 1st-party
+import { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRouteParams } from "app/routes.defs";
+// app views
+import SettingsKeyUpdateView from "../../../views/settings/SettingsKeyUpdateView";
+
+const getKeyUpdateView: ReqHandler<
+  AppRouteParams,
+  AppRoute.SETTINGS_KEY_UPDATE
+> = async (request, reply) => {
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler(SettingsKeyUpdateView.name, {});
+};
+
+export default getKeyUpdateView;

new file
app/controllers/settings/keys/getKeysListView.ts
@@ -0,0 +1,16 @@
+// 1st-party
+import { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRouteParams } from "app/routes.defs";
+// app views
+import SettingsKeysListView from "../../../views/settings/SettingsKeysListView";
+
+const getKeysListView: ReqHandler<
+  AppRouteParams,
+  AppRoute.SETTINGS_KEYS
+> = async (request, reply) => {
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler(SettingsKeysListView.name, {});
+};
+
+export default getKeysListView;

new file
app/controllers/settings/keys/index.ts
@@ -0,0 +1,13 @@
+import { default as getKeysListView } from "./getKeysListView";
+import { default as getKeyAddView } from "./getKeyAddView";
+import { default as postKeyAddAction } from "./postKeyAddAction";
+import { default as getKeyUpdateView } from "./getKeyUpdateView";
+import { default as postKeyUpdateAction } from "./postKeyUpdateAction";
+
+export const SettingsKeysController = {
+  getKeysListView,
+  getKeyAddView,
+  postKeyAddAction,
+  getKeyUpdateView,
+  postKeyUpdateAction,
+};

new file
app/controllers/settings/keys/postKeyAddAction.ts
@@ -0,0 +1,55 @@
+// 1st-party
+import { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { buildRouteLink } from "../../../utils/shared";
+import { makeUsersService } from "../../../services/user";
+import { AppRoute, AppRouteParams } from "../../../routes.defs";
+
+const postKeyAddAction: ReqHandler<
+  AppRouteParams,
+  AppRoute.SETTINGS_KEY_ADD_ACTION
+> = async (request, reply) => {
+  const { curr_user_username } = request.session.data;
+  if (curr_user_username == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const usersService = makeUsersService({ request });
+  const user = await usersService.getUserByUsername(curr_user_username);
+
+  if (user == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const { username } = request.params;
+  const { name, key } = request.body;
+
+  try {
+    await usersService.addUserSSHKey(user, name, key);
+  } catch (err) {
+    const error = err as Error;
+    request.session.data.flash_message = error.message;
+    request.session.data.flash_data = {
+      keyName: name,
+      key: key,
+    };
+  }
+
+  if (request.session.data.flash_message != null) {
+    return reply.redirect(
+      302,
+      buildRouteLink(
+        AppRoute.SETTINGS_KEY_ADD,
+        { username },
+        { encodeURIComponent: 0 }
+      )
+    );
+  }
+
+  return reply.redirect(
+    302,
+    buildRouteLink(AppRoute.SETTINGS, { username }, { encodeURIComponent: 0 })
+  );
+};
+
+export default postKeyAddAction;

new file
app/controllers/settings/keys/postKeyUpdateAction.ts
@@ -0,0 +1,11 @@
+import { ReqHandler } from "@ethicdevs/react-monolith";
+import { AppRoute, AppRouteParams } from "app/routes.defs";
+
+const postKeyUpdateAction: ReqHandler<
+  AppRouteParams,
+  AppRoute.REPOSITORY_PULL_REQUEST_MERGE_ACTION
+> = async (_request, reply) => {
+  return reply.send("...");
+};
+
+export default postKeyUpdateAction;

app/controllers/user/getUserDetailsView.ts
@@ -34,7 +34,6 @@ const getUserDetailsView: ReqHandler<
     currentUser,
     user,
     repositories: await usersService.getUserRepositories(user),
-    sshKeys: await usersService.getUserSSHKeys(user),
   });
 };
 

app/islands/AppRouter.tsx
@@ -13,6 +13,29 @@ export const AppRouterEvent = {
   NAVIGATION_ERROR: `${AppRouterEventPrefix}navigation_error`,
 };
 
+function evalPageScripts() {
+  const scripts = document.body.querySelectorAll("script");
+
+  scripts.forEach((script) => {
+    if (script.type == "module") {
+      const newScript = document.createElement("script");
+      newScript.type = script.type;
+      if (script.src != null && script.src.trim() !== "") {
+        newScript.src = script.src;
+      }
+      if (script.textContent != null && script.textContent.trim() !== "") {
+        newScript.textContent = script.textContent;
+      }
+
+      const parentNode = script.parentNode;
+      if (parentNode) {
+        script.parentNode.removeChild(script);
+        parentNode.appendChild(newScript);
+      }
+    }
+  });
+}
+
 const AppRouter: ReactIsland = () => {
   const domParserRef = useRef(
     typeof DOMParser !== "undefined" ? new DOMParser() : null

...
@@ -21,31 +44,8 @@ const AppRouter: ReactIsland = () => {
 
   const loadUrlRef = useRef<string | null>(null);
 
-  const evalPageScripts = () => {
-    const scripts = document.body.querySelectorAll("script");
-    console.log("scripts:", scripts);
-
-    scripts.forEach((script) => {
-      if (script.type == "module") {
-        const newScript = document.createElement("script");
-        newScript.type = script.type;
-        if (script.src != null && script.src.trim() !== "") {
-          newScript.src = script.src;
-        }
-        newScript.textContent = script.textContent;
-
-        const parentNode = script.parentNode;
-
-        if (parentNode) {
-          script.parentNode.removeChild(script);
-          parentNode.appendChild(newScript);
-        }
-      }
-    });
-  };
-
   useEffect(() => {
-    function run() {
+    function start() {
       const { pushState, replaceState } = window.history;
 
       window.history.pushState = function (...args) {

...
@@ -60,7 +60,9 @@ const AppRouter: ReactIsland = () => {
 
       async function navigate(
         url: URL,
-        pushState: boolean = true
+        pushState: boolean = true,
+        prefetchHtml?: string,
+        prefetchTargetUrl?: string
       ): Promise<void> {
         if (document.location.origin == url.origin) {
           if (loadUrlRef.current != null && url.href === loadUrlRef.current) {

...
@@ -90,16 +92,28 @@ const AppRouter: ReactIsland = () => {
               currentUrl
             );
 
-            const res = await fetch(url.href, {
-              redirect: "follow",
-              headers: {
-                accept: "text/html",
-                "accept-charset": "utf8",
-                "x-requested-with": "XMLHttpRequest",
-              },
-            });
-            const html = await res.text();
-            const targetUrl = new URL(res.url);
+            let html;
+            let targetUrl: URL;
+
+            if (prefetchTargetUrl != null) {
+              targetUrl = new URL(prefetchTargetUrl);
+            }
+
+            if (prefetchHtml != null) {
+              html = prefetchHtml;
+            } else {
+              const res = await fetch(url.href, {
+                headers: {
+                  accept: "text/html",
+                  "accept-charset": "utf8",
+                  "x-requested-with": "XMLHttpRequest",
+                },
+              });
+              html = await res.text();
+              targetUrl = new URL(res.url);
+            }
+
+            targetUrl = targetUrl!;
 
             loadUrlRef.current = targetUrl.href;
 

...
@@ -155,7 +169,7 @@ const AppRouter: ReactIsland = () => {
               );
             }
 
-            evalPageScripts;
+            evalPageScripts; //();
 
             document.dispatchEvent(
               new CustomEvent(AppRouterEvent.NAVIGATED, {

...
@@ -181,7 +195,7 @@ const AppRouter: ReactIsland = () => {
         }
       }
 
-      document.addEventListener("mousedown", (e) => {
+      async function onMouseDown(e: MouseEvent) {
         if (e.button !== 0) return false;
 
         const target = e.target as HTMLElement;

...
@@ -246,33 +260,72 @@ const AppRouter: ReactIsland = () => {
             };
 
             target.addEventListener("click", onClickHandler);
-            doNavigate = () => navigate(url);
+
+            const res = await fetch(url.href, {
+              headers: {
+                accept: "text/html",
+                "accept-charset": "utf8",
+                "x-requested-with": "XMLHttpRequest",
+              },
+            });
+            const html = await res.text();
+
+            doNavigate = () => navigate(url, true, html, res.url);
           }
           return false;
         }
 
         return false;
-      });
+      }
+
+      async function onFormSubmit(e: SubmitEvent) {
+        const target = e.target as HTMLElement;
 
-      window.addEventListener("popstate", (e: PopStateEvent) => {
+        if (["form"].includes(target.tagName.toLowerCase())) {
+          e.preventDefault();
+
+          const form = target as HTMLFormElement;
+          const method = form.method;
+          const url = new URL(form.action);
+          const body = new URLSearchParams(new FormData(form) as {}).toString();
+
+          const res = await fetch(url.href, {
+            method: method,
+            headers: {
+              "content-type": "application/x-www-form-urlencoded",
+              accept: "text/html",
+              "accept-charset": "utf8",
+              "x-requested-with": "XMLHttpRequest",
+            },
+            body,
+          });
+          const html = await res.text();
+          navigate(url, true, html, res.url);
+        }
+      }
+
+      function onPopState(e: PopStateEvent) {
         const url = new URL(e.state.href);
         if (document.location.origin == url.origin) {
           navigate(url, false).then(() => {
             document.documentElement.scrollTop = e.state.scrollTop || 0;
           });
         }
-      });
+      }
 
-      // window.addEventListener("replaceState", () =>
-      //   console.log("replaceState event")
-      // );
+      document.addEventListener("submit", onFormSubmit);
+      document.addEventListener("mousedown", onMouseDown);
 
-      // window.addEventListener("pushState", () =>
-      //   console.log("pushState event")
-      // );
-    }
+      window.addEventListener("popstate", onPopState);
 
-    run();
+      return () => {
+        document.removeEventListener("submit", onFormSubmit);
+        document.removeEventListener("mousedown", onMouseDown);
+
+        window.removeEventListener("popstate", onPopState);
+      };
+    }
+    start();
   }, []);
 
   return <></>;

app/routes.defs.tsx
@@ -41,10 +41,14 @@ export enum AppRoute {
   REPOSITORY_PULL_REQUESTS = "repository.pull_requests",
   REPOSITORY_SHOW_OBJECT = "repository.show_object",
   SYNTAX_HIGHLIGHT_HIGHLIGHT_CODE_ACTION = "syntax_highlight.highlight_code.action",
+  SETTINGS = "settings.home",
+  SETTINGS_KEYS = "settings.keys.list",
+  SETTINGS_KEY_ADD = "settings.keys.add",
+  SETTINGS_KEY_ADD_ACTION = "settings.keys.add.action",
+  SETTINGS_KEY_UPDATE = "settings.keys.update",
+  SETTINGS_KEY_UPDATE_ACTION = "settings.keys.update.action",
   USER_DASHBOARD = "user.dashboard",
   USER_DETAILS = "user.details",
-  USER_SSH_KEY_ADD = "user.ssh_key.add",
-  USER_SSH_KEY_ADD_ACTION = "user.ssh_key.add.action",
 }
 
 export const AppRoutePaths: Record<AppRoute, string> = {

...
@@ -90,12 +94,17 @@ export const AppRoutePaths: Record<AppRoute, string> = {
     "/:orgSlug/:repoSlug/pulls/:pullUid/edit",
   [AppRoute.REPOSITORY_PULL_REQUESTS]: "/:orgSlug/:repoSlug/pulls",
   [AppRoute.REPOSITORY_SHOW_OBJECT]: "/:orgSlug/:repoSlug/show/:objectId",
+  [AppRoute.SETTINGS]: "/@:username/settings",
+  [AppRoute.SETTINGS_KEYS]: "/@:username/settings/keys",
+  [AppRoute.SETTINGS_KEY_ADD]: "/@:username/settings/keys/add",
+  [AppRoute.SETTINGS_KEY_ADD_ACTION]: "/@:username/settings/keys/add",
+  [AppRoute.SETTINGS_KEY_UPDATE]: "/@:username/settings/keys/:keyId/edit",
+  [AppRoute.SETTINGS_KEY_UPDATE_ACTION]:
+    "/@:username/settings/keys/:keyId/edit",
   [AppRoute.SYNTAX_HIGHLIGHT_HIGHLIGHT_CODE_ACTION]:
     "/api/syntax/highlight/:outputFormat",
   [AppRoute.USER_DASHBOARD]: "/dashboard",
   [AppRoute.USER_DETAILS]: "/@:username",
-  [AppRoute.USER_SSH_KEY_ADD]: "/@:username/keys/add",
-  [AppRoute.USER_SSH_KEY_ADD_ACTION]: "/@:username/keys/add",
 };
 
 export interface AppRouteParams {

...
@@ -326,36 +335,54 @@ export interface AppRouteParams {
       objectId: string;
     };
   };
-  [AppRoute.SYNTAX_HIGHLIGHT_HIGHLIGHT_CODE_ACTION]: {
+  [AppRoute.SETTINGS]: undefined;
+  [AppRoute.SETTINGS_KEYS]: undefined;
+  [AppRoute.SETTINGS_KEY_ADD]: {
     params: {
-      outputFormat?: "html" | "json";
-    };
-    body: {
-      code: string;
-      language: string;
-      theme_scheme: AppThemeScheme;
+      username: string;
     };
   };
-  [AppRoute.USER_DASHBOARD]: undefined;
-  [AppRoute.USER_DETAILS]: {
+  [AppRoute.SETTINGS_KEY_ADD_ACTION]: {
     params: {
       username: string;
     };
+    body: {
+      name: string;
+      key: string;
+    };
   };
-  [AppRoute.USER_SSH_KEY_ADD]: {
+  [AppRoute.SETTINGS_KEY_UPDATE]: {
     params: {
       username: string;
+      keyId: string;
     };
   };
-  [AppRoute.USER_SSH_KEY_ADD_ACTION]: {
+  [AppRoute.SETTINGS_KEY_UPDATE_ACTION]: {
     params: {
       username: string;
+      keyId: string;
     };
     body: {
       name: string;
       key: string;
     };
   };
+  [AppRoute.SYNTAX_HIGHLIGHT_HIGHLIGHT_CODE_ACTION]: {
+    params: {
+      outputFormat?: "html" | "json";
+    };
+    body: {
+      code: string;
+      language: string;
+      theme_scheme: AppThemeScheme;
+    };
+  };
+  [AppRoute.USER_DASHBOARD]: undefined;
+  [AppRoute.USER_DETAILS]: {
+    params: {
+      username: string;
+    };
+  };
 }
 
 export const AppRouteSchemas: Record<AppRoute, undefined | FastifySchema> = {

...
@@ -954,81 +981,126 @@ export const AppRouteSchemas: Record<AppRoute, undefined | FastifySchema> = {
       },
     },
   },
-  [AppRoute.SYNTAX_HIGHLIGHT_HIGHLIGHT_CODE_ACTION]: {
+  [AppRoute.SETTINGS]: undefined,
+  [AppRoute.SETTINGS_KEYS]: undefined,
+  [AppRoute.SETTINGS_KEY_ADD]: {
     params: {
       type: "object",
-      required: [],
+      required: ["username"],
       additionalProperties: false,
       properties: {
-        outputFormat: {
+        username: {
           type: "string",
-          enum: ["html", "json"],
         },
       },
     },
-    body: {
+  },
+  [AppRoute.SETTINGS_KEY_ADD_ACTION]: {
+    params: {
       type: "object",
-      required: ["code", "language", "theme_scheme"],
+      required: ["username"],
       additionalProperties: false,
       properties: {
-        code: {
+        username: {
           type: "string",
         },
-        language: {
+      },
+    },
+    body: {
+      type: "object",
+      required: ["name", "key"],
+      additionalProperties: false,
+      properties: {
+        username: {
           type: "string",
         },
-        theme_scheme: {
+        key: {
           type: "string",
-          enum: ["light", "dark"],
         },
       },
     },
   },
-  [AppRoute.USER_DASHBOARD]: undefined,
-  [AppRoute.USER_DETAILS]: {
+  [AppRoute.SETTINGS_KEY_UPDATE]: {
     params: {
       type: "object",
-      required: ["username"],
+      required: ["username", "keyId"],
       additionalProperties: false,
       properties: {
         username: {
           type: "string",
         },
+        keyId: {
+          type: "string",
+        },
       },
     },
   },
-  [AppRoute.USER_SSH_KEY_ADD]: {
+  [AppRoute.SETTINGS_KEY_UPDATE_ACTION]: {
     params: {
       type: "object",
-      required: ["username"],
+      required: ["username", "keyId"],
+      additionalProperties: false,
+      properties: {
+        username: {
+          type: "string",
+        },
+        keyId: {
+          type: "string",
+        },
+      },
+    },
+    body: {
+      type: "object",
+      required: ["name", "key"],
       additionalProperties: false,
       properties: {
         username: {
           type: "string",
         },
+        key: {
+          type: "string",
+        },
       },
     },
   },
-  [AppRoute.USER_SSH_KEY_ADD_ACTION]: {
+  [AppRoute.SYNTAX_HIGHLIGHT_HIGHLIGHT_CODE_ACTION]: {
     params: {
       type: "object",
-      required: ["username"],
+      required: [],
       additionalProperties: false,
       properties: {
-        username: {
+        outputFormat: {
           type: "string",
+          enum: ["html", "json"],
         },
       },
     },
     body: {
       type: "object",
-      required: ["name", "key"],
+      required: ["code", "language", "theme_scheme"],
       additionalProperties: false,
       properties: {
-        username: {
+        code: {
           type: "string",
         },
-        key: {
+        language: {
+          type: "string",
+        },
+        theme_scheme: {
+          type: "string",
+          enum: ["light", "dark"],
+        },
+      },
+    },
+  },
+  [AppRoute.USER_DASHBOARD]: undefined,
+  [AppRoute.USER_DETAILS]: {
+    params: {
+      type: "object",
+      required: ["username"],
+      additionalProperties: false,
+      properties: {
+        username: {
           type: "string",
         },
       },

@@ -26,6 +26,7 @@ import {
   RepositoryController,
   RepositoryPullRequestsController,
   SSHAuthController,
+  SettingsController,
   SyntaxHighlightController,
   ThemeController,
   UserController,

...
@@ -128,19 +129,48 @@ const RootAppRouter: AppRouter<AppRouteParams> = () => {
           path={AppRoutePaths[AppRoute.USER_DETAILS]}
           handler={UserController.getUserDetailsView}
         />
+        {/* --- */}
+        <Route
+          name={AppRoute.SETTINGS}
+          method={"GET"}
+          path={AppRoutePaths[AppRoute.SETTINGS]}
+          preHandler={loggedOrLoginRedirect}
+          handler={SettingsController.getSettingsView}
+        />
+        <Route
+          name={AppRoute.SETTINGS_KEYS}
+          method={"GET"}
+          path={AppRoutePaths[AppRoute.SETTINGS_KEYS]}
+          preHandler={loggedOrLoginRedirect}
+          handler={SettingsController.getKeysListView}
+        />
+        <Route
+          name={AppRoute.SETTINGS_KEY_ADD}
+          method={"GET"}
+          path={AppRoutePaths[AppRoute.SETTINGS_KEY_ADD]}
+          preHandler={loggedOrLoginRedirect}
+          handler={SettingsController.getKeyAddView}
+        />
+        <Route
+          name={AppRoute.SETTINGS_KEY_ADD_ACTION}
+          method={"POST"}
+          path={AppRoutePaths[AppRoute.SETTINGS_KEY_ADD_ACTION]}
+          preHandler={loggedOrLoginRedirect}
+          handler={SettingsController.postKeyAddAction}
+        />
         <Route
-          name={AppRoute.USER_SSH_KEY_ADD}
+          name={AppRoute.SETTINGS_KEY_UPDATE}
           method={"GET"}
-          path={AppRoutePaths[AppRoute.USER_SSH_KEY_ADD]}
+          path={AppRoutePaths[AppRoute.SETTINGS_KEY_UPDATE]}
           preHandler={loggedOrLoginRedirect}
-          handler={UserController.getUserSSHKeyAddView}
+          handler={SettingsController.getKeyUpdateView}
         />
         <Route
-          name={AppRoute.USER_SSH_KEY_ADD_ACTION}
+          name={AppRoute.SETTINGS_KEY_UPDATE_ACTION}
           method={"POST"}
-          path={AppRoutePaths[AppRoute.USER_SSH_KEY_ADD_ACTION]}
+          path={AppRoutePaths[AppRoute.SETTINGS_KEY_UPDATE_ACTION]}
           preHandler={loggedOrLoginRedirect}
-          handler={UserController.postUserSSHKeyAddAction}
+          handler={SettingsController.postKeyUpdateAction}
         />
         {/* --- */}
         <Route

@@ -39,6 +39,7 @@ import {
   getGitStamp,
   localAppDomainPreHandler,
   makeRequestHandler,
+  sessionSetupPreHandler,
 } from "./utils/server";
 
 let server: null | AppServer<AppRouteParams> = null;

...
@@ -328,6 +329,7 @@ async function main(): Promise<AppServer> {
           curr_user_uid: null,
           curr_user_username: null,
           curr_user_role: GlobalRole.GUEST,
+          flash_data: null,
           flash_message: null,
           flash_message_shown_once: false,
           two_factor_lock: false,

...
@@ -335,6 +337,9 @@ async function main(): Promise<AppServer> {
         },
       });
 
+      // check that a session is started, or start it.
+      s.addHook("preHandler", sessionSetupPreHandler);
+
       // serve the import-map service worker interceptor
       s.get("/interceptor-imsw.js", {}, async (_, reply) => {
         return reply.sendFile("interceptor-imsw.js");

new file
app/services/user/addUserSSHKey.ts
@@ -0,0 +1,70 @@
+// std
+import fs from "fs";
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import { User } from "@prisma/client";
+// app
+import type { UsersServiceDeps } from "./types";
+
+const SSH_RSA_KEY_REGEXP =
+  /^ssh-rsa AAAA[0-9A-Za-z+\/]+[=]{0,3} ([^@]+@[^@]+)$/i;
+
+const addUserSSHKey: ServiceMethodFactory<
+  UsersServiceDeps,
+  [User, string, string],
+  Promise<boolean>
+> = ({ request }) => {
+  return async (user, name, key) => {
+    // 0. Validate key is actually a ssh-rsa key
+    if (key.match(SSH_RSA_KEY_REGEXP) == null) {
+      throw new Error(
+        "Invalid public key. Please provide a valid SSH RSA public key."
+      );
+    }
+
+    const existingKey = await request.prisma.userSSHKey.findFirst({
+      where: {
+        key: key,
+      },
+    });
+
+    // 1. Check if public key is already registered
+    if (existingKey != null) {
+      throw new Error(
+        "Public key is already registered. Please use another one."
+      );
+    }
+
+    // 2. Add key to database
+    const userKey = await request.prisma.userSSHKey.create({
+      data: {
+        name: name,
+        key: key,
+        user: {
+          connect: {
+            id: user.id,
+          },
+        },
+      },
+    });
+
+    if (userKey == null) {
+      return false;
+    }
+
+    let line = "";
+    line += `command="ssh_command ${user.username}",`;
+    line += "no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ";
+    line += `${key}\n`;
+
+    // 3. Add key to authorized_keys
+    fs.appendFileSync("/home/git/.ssh/authorized_keys", line, {
+      encoding: "utf8",
+    });
+
+    return true;
+  };
+};
+
+export default addUserSSHKey;

app/services/user/index.ts
@@ -3,6 +3,7 @@ import { makeService } from "@ethicdevs/react-monolith";
 // service
 import type { UsersServiceAPI, UsersServiceDeps } from "./types";
 // service methods
+import { default as makeAddUserSSHKey } from "./addUserSSHKey";
 import { default as makeGetUserByEmailAddress } from "./getUserByEmailAddress";
 import { default as makeGetUserById } from "./getUserById";
 import { default as makeGetUserByUsername } from "./getUserByUsername";

...
@@ -12,6 +13,7 @@ import { default as makeGetUserRepositories } from "./getUserRepositories";
 import { default as makeGetUserSSHKeys } from "./getUserSSHKeys";
 
 export const makeUsersService = makeService<UsersServiceAPI, UsersServiceDeps>({
+  addUserSSHKey: makeAddUserSSHKey,
   getUserByEmailAddress: makeGetUserByEmailAddress,
   getUserById: makeGetUserById,
   getUserByUsername: makeGetUserByUsername,

app/services/user/types.ts
@@ -13,6 +13,7 @@ import {
 } from "@prisma/client";
 
 export interface UsersServiceAPI extends ServiceApiContract {
+  addUserSSHKey(user: User, name: string, key: string): Promise<boolean>;
   getUserById(userId: string): Promise<User | null>;
   getUserByUsername(username: string): Promise<User | null>;
   getUserByEmailAddress(emailAddress: string): Promise<User | null>;

@@ -15,6 +15,7 @@ export interface AppSessionData extends Prisma.JsonObject {
   sessionId: null | string;
   authenticated: boolean;
   auth_redirect_to: null | string;
+  flash_data: null | Record<string, any>;
   flash_message: null | string;
   flash_message_shown_once: boolean;
   curr_user_avatar_uri: null | string;

app/utils/server/sessionSetupPreHandler.ts
@@ -12,5 +12,18 @@ export const sessionSetupPreHandler: preHandlerHookHandler = (
     reply.setCookie("theme_scheme", Const.DEFAULT_THEME_SCHEME);
   }
 
+  // Handle flash messages logic
+  if (request.session.data.flash_message != null) {
+    if (request.session.data.flash_message_shown_once) {
+      // Reset the flash message one request after it is shown.
+      request.session.data.flash_message = null;
+      request.session.data.flash_data = null;
+      request.session.data.flash_message_shown_once = false;
+    } else {
+      // Set the flash message as shown once so it can be hidden on next request.
+      request.session.data.flash_message_shown_once = true;
+    }
+  }
+
   done();
 };

app/utils/shared/buildRouteLink.ts
@@ -2,7 +2,7 @@ import { AppRoute, AppRoutePaths, AppRouteParams } from "../../routes.defs";
 
 interface BuildLinkOptions {
   // @default true
-  encodeURIComponent?: boolean;
+  encodeURIComponent?: boolean | number;
 }
 
 export default function buildRouteLink<P extends AppRoute>(

...
@@ -11,7 +11,7 @@ export default function buildRouteLink<P extends AppRoute>(
     ? AppRouteParams[P]["params"]
     : {} | null,
   options?: BuildLinkOptions
-): typeof AppRoutePaths[P] {
+): (typeof AppRoutePaths)[P] {
   const path = AppRoutePaths[route];
   return buildPathLink(path, routeParams, options);
 }

...
@@ -41,7 +41,7 @@ export function buildPathLink<P extends AppRoute>(
     if (part.trim() === "") {
       linkBuilder.push("");
     } else if (
-      "*" in routeParams &&
+      "*" in (routeParams as any) &&
       (routeParams as any)["*"] != null &&
       part === "*"
     ) {

...
@@ -62,7 +62,7 @@ export function buildPathLink<P extends AppRoute>(
   const shouldEncodeURIComponents = !!(
     options == null ||
     options.encodeURIComponent == null ||
-    options.encodeURIComponent !== false
+    options.encodeURIComponent != false
   );
 
   return linkBuilder

new file
app/views/settings/SettingsKeyAddView.tsx
@@ -0,0 +1,85 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// app
+import type { CommonProps } from "../../types";
+import { buildRouteLink } from "../../utils/shared";
+import { AppRoute } from "../../routes.defs";
+import {
+  Button,
+  Card,
+  Grid,
+  Layout,
+  PageWrapper,
+  TextArea,
+  TextInput,
+} from "../../components";
+
+export interface SettingsKeyAddViewProps extends CommonProps {
+  flashMessage?: string;
+  flashData?: {
+    name: string;
+    key: string;
+  };
+}
+
+const SettingsKeyAddView: ReactView<SettingsKeyAddViewProps> = ({
+  commonProps,
+  flashMessage,
+  flashData,
+}) => {
+  const username = commonProps.currentUserUsername!;
+  return (
+    <Layout {...commonProps}>
+      <PageWrapper>
+        <Grid.Col fluid nowrap gap={8}>
+          <h1 style={{ margin: 0 }}>Add Key</h1>
+          <Card themeScheme={commonProps.themeScheme}>
+            <form
+              method={"POST"}
+              action={buildRouteLink(
+                AppRoute.SETTINGS_KEY_ADD_ACTION,
+                { username: username },
+                { encodeURIComponent: 0 }
+              )}
+            >
+              <Grid.Col fluid nowrap gap={16} alignItems={"flex-end"}>
+                <Grid.Col fluid nowrap gap={4}>
+                  <label>
+                    <strong>Name:</strong>
+                  </label>
+                  <TextInput
+                    name={"name"}
+                    themeScheme={commonProps.themeScheme}
+                    placeholder={"Key name..."}
+                    defaultValue={flashData?.name}
+                  />
+                </Grid.Col>
+                <Grid.Col fluid nowrap gap={4}>
+                  <label>
+                    <strong>Key:</strong>
+                  </label>
+                  <TextArea
+                    style={{ height: 200, maxWidth: "100%", minWidth: "100%" }}
+                    name={"key"}
+                    themeScheme={commonProps.themeScheme}
+                    placeholder={"Public key..."}
+                  >
+                    {flashData?.key}
+                  </TextArea>
+                </Grid.Col>
+                {flashMessage != null && (
+                  <strong style={{ color: "red" }}>{flashMessage}</strong>
+                )}
+                <Button type={"submit"}>Add Key</Button>
+              </Grid.Col>
+            </form>
+          </Card>
+        </Grid.Col>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+export default SettingsKeyAddView;

new file
app/views/settings/SettingsKeyUpdateView.tsx
@@ -0,0 +1,23 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// app
+import type { CommonProps } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+
+interface SettingsKeyUpdateViewProps extends CommonProps {}
+
+const SettingsKeyUpdateView: ReactView<SettingsKeyUpdateViewProps> = ({
+  commonProps,
+}) => {
+  return (
+    <Layout {...commonProps}>
+      <PageWrapper>
+        <h1>Key Update View</h1>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+export default SettingsKeyUpdateView;

new file
app/views/settings/SettingsKeysListView.tsx
@@ -0,0 +1,23 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// app
+import type { CommonProps } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+
+interface SettingsKeysListViewProps extends CommonProps {}
+
+const SettingsKeysListView: ReactView<SettingsKeysListViewProps> = ({
+  commonProps,
+}) => {
+  return (
+    <Layout {...commonProps}>
+      <PageWrapper>
+        <h1>Keys List View</h1>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+export default SettingsKeysListView;

new file
app/views/settings/SettingsView.tsx
@@ -0,0 +1,83 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// generated via script[generate:prisma]
+import { UserSSHKey } from "@prisma/client";
+// app
+import type { CommonProps } from "../../types";
+import { buildRouteLink } from "../../utils/shared";
+import { AppRoute } from "../../routes.defs";
+import {
+  ButtonAnchor,
+  Card,
+  Grid,
+  IslandWrapper,
+  Layout,
+  PageWrapper,
+} from "../../components";
+// app islands
+import SSHKeyItem from "../../islands/SSHKeyItem";
+
+export interface SettingsViewProps extends CommonProps {
+  sshKeys: UserSSHKey[];
+}
+
+const SettingsView: ReactView<SettingsViewProps> = ({
+  commonProps,
+  sshKeys,
+}) => {
+  return (
+    <Layout {...commonProps}>
+      <PageWrapper>
+        <Grid.Col fluid nowrap gap={8}>
+          <h1 style={{ margin: 0 }}>Settings</h1>
+          <Grid.Row fluid gap={16}>
+            <Grid.Col flex={0.33} nowrap>
+              <Card themeScheme={commonProps.themeScheme}>
+                <div>master</div>
+              </Card>
+            </Grid.Col>
+            <Grid.Col fluid nowrap>
+              <Card themeScheme={commonProps.themeScheme}>
+                <Grid.Col fluid nowrap gap={8}>
+                  <Grid.Row
+                    fluid
+                    nowrap
+                    justifyContent="space-between"
+                    gap={8}
+                    alignItems="center"
+                  >
+                    <h2 style={{ margin: 0 }}>Your SSH key</h2>
+                    <ButtonAnchor
+                      href={buildRouteLink(
+                        AppRoute.SETTINGS_KEY_ADD,
+                        { username: commonProps.currentUserUsername! },
+                        { encodeURIComponent: 0 }
+                      )}
+                    >
+                      Add
+                    </ButtonAnchor>
+                  </Grid.Row>
+                  {sshKeys.map((key, idx) => (
+                    <IslandWrapper
+                      data-islandid={`${SSHKeyItem.name}$$${idx}`}
+                      key={key.id}
+                    >
+                      <SSHKeyItem
+                        themeScheme={commonProps.themeScheme}
+                        sshKey={key}
+                      />
+                    </IslandWrapper>
+                  ))}
+                </Grid.Col>
+              </Card>
+            </Grid.Col>
+          </Grid.Row>
+        </Grid.Col>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+export default SettingsView;

app/views/user/UserDetailsView.tsx
@@ -3,12 +3,7 @@ import type { ReactView } from "@ethicdevs/react-monolith";
 // 3rd-party
 import React from "react";
 // generated via script[generate:prisma]
-import type {
-  Organization,
-  Repository,
-  User,
-  UserSSHKey,
-} from "@prisma/client";
+import type { Organization, Repository, User } from "@prisma/client";
 // app
 import type { CommonProps } from "../../types";
 import { buildRouteLink } from "../../utils/shared";

...
@@ -22,75 +17,49 @@ import {
 } from "../../components";
 // app islands
 import RepositoriesList from "../../islands/RepositoriesList";
-import SSHKeyItem from "../../islands/SSHKeyItem";
 
 export interface UserDetailsViewProps extends CommonProps {
   user: User;
   currentUser: User | null;
   repositories: (Repository & { parentOrg: Organization })[];
-  sshKeys: UserSSHKey[];
 }
 
 const UserDetailsView: ReactView<UserDetailsViewProps> = ({
   commonProps,
-  currentUser,
   user,
+  currentUser,
   repositories,
-  sshKeys,
 }) => {
   return (
     <Layout {...commonProps}>
       <PageWrapper>
-        <h1>{user.displayName || user.username}</h1>
-        <h2 style={{ opacity: 0.67 }}>
-          {currentUser != null && currentUser.id === user.id
-            ? "Your repositories"
-            : `Public repositories from ${user.displayName || user.username}`}
-        </h2>
+        <Grid.Row fluid gap={8}>
+          <Grid.Col fluid nowrap gap={4}>
+            <h1 style={{ margin: 0 }}>{user.displayName || user.username}</h1>
+            <h2 style={{ margin: 0, opacity: 0.67 }}>
+              {currentUser != null && currentUser.id === user.id
+                ? "Your repositories"
+                : `Public repositories from ${
+                    user.displayName || user.username
+                  }`}
+            </h2>
+          </Grid.Col>
+          <ButtonAnchor
+            href={buildRouteLink(
+              AppRoute.SETTINGS,
+              { username: user.username },
+              { encodeURIComponent: 0 }
+            )}
+          >
+            Settings
+          </ButtonAnchor>
+        </Grid.Row>
         <IslandWrapper data-islandid={`${RepositoriesList.name}$$0`}>
           <RepositoriesList
             repositories={repositories}
             themeScheme={commonProps.themeScheme}
           />
         </IslandWrapper>
-        {currentUser != null && (
-          <>
-            <Grid.Row
-              fluid
-              nowrap
-              gap={24}
-              justifyContent="space-between"
-              alignItems="center"
-            >
-              <h2 style={{ marginBottom: 16 }}>Your SSH key</h2>
-              <ButtonAnchor
-                style={{ fontSize: 16, minHeight: 40, padding: "0 16px" }}
-                href={buildRouteLink(
-                  AppRoute.USER_SSH_KEY_ADD,
-                  {
-                    username: currentUser.username,
-                  },
-                  {
-                    encodeURIComponent: false,
-                  }
-                )}
-              >
-                Add
-              </ButtonAnchor>
-            </Grid.Row>
-            {sshKeys.map((key, idx) => (
-              <IslandWrapper
-                data-islandid={`${SSHKeyItem.name}$$${idx}`}
-                key={key.id}
-              >
-                <SSHKeyItem
-                  sshKey={key}
-                  themeScheme={commonProps.themeScheme}
-                />
-              </IslandWrapper>
-            ))}
-          </>
-        )}
       </PageWrapper>
     </Layout>
   );

@@ -31,6 +31,7 @@ services:
     volumes:
       - ./data/gitfoss_repos:/var/lib/gitfoss/repos
       - ./data/gitfoss_repos:/home/git/repos
+      - ./data/authorized_keys:/home/git/.ssh/authorized_keys
     env_file: .env.docker
     # environment:
     #   - COOKIE_NAME=gitfoss_ssid