feat: add settings page, allow to add keys@@ -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"
@@ -7,6 +7,8 @@ export const Card = styled.div<WithThemeSchemeProp>`
display: flex;
flex-flow: column nowrap;
+ width: 100%;
+
padding: 16px;
${({ themeScheme = "light" }) => css`
@@ -66,6 +66,9 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
a > * {
pointer-events: none;
}
+ form {
+ width: 100%;
+ }
`),
}}
/>
@@ -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";
@@ -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;
@@ -0,0 +1,8 @@
+import { SettingsKeysController } from "./keys";
+
+import { default as getSettingsView } from "./getSettingsView";
+
+export const SettingsController = {
+ ...SettingsKeysController,
+ getSettingsView,
+};
@@ -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;
@@ -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;
@@ -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;
@@ -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,
+};
@@ -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;
@@ -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;
@@ -34,7 +34,6 @@ const getUserDetailsView: ReqHandler<
currentUser,
user,
repositories: await usersService.getUserRepositories(user),
- sshKeys: await usersService.getUserSSHKeys(user),
});
};
@@ -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 <></>;
@@ -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");
@@ -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;
@@ -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,
@@ -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;
@@ -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();
};
@@ -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
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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