mirror of
https://github.com/moghtech/komodo.git
synced 2026-08-08 13:46:11 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7406c292a1 | ||
|
|
427475651c | ||
|
|
ae0c8bf98a | ||
|
|
a7592f4561 | ||
|
|
a199c8fe27 | ||
|
|
f7296c73b1 | ||
|
|
0eaf8b5f01 | ||
|
|
6919582f69 |
@@ -0,0 +1,158 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
|
||||
use derive_variants::{EnumVariants, ExtractVariant};
|
||||
use komodo_client::{api::auth::*, entities::user::User};
|
||||
use reqwest::StatusCode;
|
||||
use resolver_api::Resolve;
|
||||
use response::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serror::{AddStatusCode, Json};
|
||||
use typeshare::typeshare;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
get_user_id_from_headers,
|
||||
github::{self, client::github_oauth_client},
|
||||
google::{self, client::google_oauth_client},
|
||||
oidc::{self, client::oidc_client},
|
||||
},
|
||||
config::core_config,
|
||||
helpers::query::get_user,
|
||||
state::jwt_client,
|
||||
};
|
||||
|
||||
use super::Variant;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AuthArgs {
|
||||
pub headers: HeaderMap,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
|
||||
)]
|
||||
#[args(AuthArgs)]
|
||||
#[response(Response)]
|
||||
#[error(serror::Error)]
|
||||
#[variant_derive(Debug)]
|
||||
#[serde(tag = "type", content = "params")]
|
||||
#[allow(clippy::enum_variant_names, clippy::large_enum_variant)]
|
||||
pub enum AuthRequest {
|
||||
GetLoginOptions(GetLoginOptions),
|
||||
SignUpLocalUser(SignUpLocalUser),
|
||||
LoginLocalUser(LoginLocalUser),
|
||||
ExchangeForJwt(ExchangeForJwt),
|
||||
GetUser(GetUser),
|
||||
}
|
||||
|
||||
pub fn router() -> Router {
|
||||
let mut router = Router::new()
|
||||
.route("/", post(handler))
|
||||
.route("/{variant}", post(variant_handler));
|
||||
|
||||
if core_config().local_auth {
|
||||
info!("🔑 Local Login Enabled");
|
||||
}
|
||||
|
||||
if github_oauth_client().is_some() {
|
||||
info!("🔑 Github Login Enabled");
|
||||
router = router.nest("/github", github::router())
|
||||
}
|
||||
|
||||
if google_oauth_client().is_some() {
|
||||
info!("🔑 Google Login Enabled");
|
||||
router = router.nest("/google", google::router())
|
||||
}
|
||||
|
||||
if core_config().oidc_enabled {
|
||||
info!("🔑 OIDC Login Enabled");
|
||||
router = router.nest("/oidc", oidc::router())
|
||||
}
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
async fn variant_handler(
|
||||
headers: HeaderMap,
|
||||
Path(Variant { variant }): Path<Variant>,
|
||||
Json(params): Json<serde_json::Value>,
|
||||
) -> serror::Result<axum::response::Response> {
|
||||
let req: AuthRequest = serde_json::from_value(json!({
|
||||
"type": variant,
|
||||
"params": params,
|
||||
}))?;
|
||||
handler(headers, Json(req)).await
|
||||
}
|
||||
|
||||
#[instrument(name = "AuthHandler", level = "debug", skip(headers))]
|
||||
async fn handler(
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<AuthRequest>,
|
||||
) -> serror::Result<axum::response::Response> {
|
||||
let timer = Instant::now();
|
||||
let req_id = Uuid::new_v4();
|
||||
debug!(
|
||||
"/auth request {req_id} | METHOD: {:?}",
|
||||
request.extract_variant()
|
||||
);
|
||||
let res = request.resolve(&AuthArgs { headers }).await;
|
||||
if let Err(e) = &res {
|
||||
debug!("/auth request {req_id} | error: {:#}", e.error);
|
||||
}
|
||||
let elapsed = timer.elapsed();
|
||||
debug!("/auth request {req_id} | resolve time: {elapsed:?}");
|
||||
res.map(|res| res.0)
|
||||
}
|
||||
|
||||
fn login_options_response() -> GetLoginOptionsResponse {
|
||||
let config = core_config();
|
||||
GetLoginOptionsResponse {
|
||||
local: config.local_auth,
|
||||
github: github_oauth_client().is_some(),
|
||||
google: google_oauth_client().is_some(),
|
||||
oidc: oidc_client().load().is_some(),
|
||||
registration_disabled: config.disable_user_registration,
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<AuthArgs> for GetLoginOptions {
|
||||
#[instrument(name = "GetLoginOptions", level = "debug", skip(self))]
|
||||
async fn resolve(
|
||||
self,
|
||||
_: &AuthArgs,
|
||||
) -> serror::Result<GetLoginOptionsResponse> {
|
||||
Ok(login_options_response())
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<AuthArgs> for ExchangeForJwt {
|
||||
#[instrument(name = "ExchangeForJwt", level = "debug", skip(self))]
|
||||
async fn resolve(
|
||||
self,
|
||||
_: &AuthArgs,
|
||||
) -> serror::Result<ExchangeForJwtResponse> {
|
||||
jwt_client()
|
||||
.redeem_exchange_token(&self.token)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve<AuthArgs> for GetUser {
|
||||
#[instrument(name = "GetUser", level = "debug", skip(self))]
|
||||
async fn resolve(
|
||||
self,
|
||||
AuthArgs { headers }: &AuthArgs,
|
||||
) -> serror::Result<User> {
|
||||
let user_id = get_user_id_from_headers(headers)
|
||||
.await
|
||||
.status_code(StatusCode::UNAUTHORIZED)?;
|
||||
get_user(&user_id)
|
||||
.await
|
||||
.status_code(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
import { KOMODO_BASE_URL } from "@main";
|
||||
import { KomodoClient, Types } from "komodo_client";
|
||||
import {
|
||||
AuthResponses,
|
||||
ExecuteResponses,
|
||||
ReadResponses,
|
||||
UserResponses,
|
||||
WriteResponses,
|
||||
} from "komodo_client/dist/responses";
|
||||
import {
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { UsableResource } from "@types";
|
||||
import { useToast } from "@ui/use-toast";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { has_minimum_permissions, RESOURCE_TARGETS } from "./utils";
|
||||
|
||||
export const atomWithStorage = <T>(key: string, init: T) => {
|
||||
const stored = localStorage.getItem(key);
|
||||
const inner = atom(stored ? JSON.parse(stored) : init);
|
||||
|
||||
return atom(
|
||||
(get) => get(inner),
|
||||
(_, set, newValue) => {
|
||||
set(inner, newValue);
|
||||
localStorage.setItem(key, JSON.stringify(newValue));
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
type LoginTokens = {
|
||||
/** Current User ID */
|
||||
current: string | undefined;
|
||||
/** Array of logged in user ids / tokens */
|
||||
tokens: Array<Types.JwtResponse>;
|
||||
};
|
||||
|
||||
const LOGIN_TOKENS_KEY = "komodo-auth-tokens-v1";
|
||||
|
||||
export const LOGIN_TOKENS = (() => {
|
||||
const stored = localStorage.getItem(LOGIN_TOKENS_KEY);
|
||||
|
||||
let tokens: LoginTokens = stored
|
||||
? JSON.parse(stored)
|
||||
: { current: undefined, tokens: [] };
|
||||
|
||||
const update_local_storage = () => {
|
||||
localStorage.setItem(LOGIN_TOKENS_KEY, JSON.stringify(tokens));
|
||||
};
|
||||
|
||||
const accounts = () => {
|
||||
const current = tokens.tokens.find((t) => t.user_id === tokens.current);
|
||||
const filtered = tokens.tokens.filter((t) => t.user_id !== tokens.current);
|
||||
return current ? [current, ...filtered] : filtered;
|
||||
};
|
||||
|
||||
const add_and_change = (user_id: string, jwt: string) => {
|
||||
const filtered = tokens.tokens.filter((t) => t.user_id !== user_id);
|
||||
filtered.push({ user_id, jwt });
|
||||
filtered.sort();
|
||||
tokens = {
|
||||
current: user_id,
|
||||
tokens: filtered,
|
||||
};
|
||||
update_local_storage();
|
||||
};
|
||||
|
||||
const remove = (user_id: string) => {
|
||||
const filtered = tokens.tokens.filter((t) => t.user_id !== user_id);
|
||||
tokens = {
|
||||
current:
|
||||
tokens.current === user_id ? filtered[0]?.user_id : tokens.current,
|
||||
tokens: filtered,
|
||||
};
|
||||
update_local_storage();
|
||||
};
|
||||
|
||||
const remove_all = () => {
|
||||
tokens = {
|
||||
current: undefined,
|
||||
tokens: [],
|
||||
};
|
||||
update_local_storage();
|
||||
};
|
||||
|
||||
const change = (to_id: string) => {
|
||||
tokens = {
|
||||
current: to_id,
|
||||
tokens: tokens.tokens,
|
||||
};
|
||||
update_local_storage();
|
||||
};
|
||||
|
||||
return {
|
||||
jwt: () =>
|
||||
tokens.current
|
||||
? (tokens.tokens.find((t) => t.user_id === tokens.current)?.jwt ?? "")
|
||||
: "",
|
||||
accounts,
|
||||
add_and_change,
|
||||
remove,
|
||||
remove_all,
|
||||
change,
|
||||
};
|
||||
})();
|
||||
|
||||
export const komodo_client = () =>
|
||||
KomodoClient(KOMODO_BASE_URL, {
|
||||
type: "jwt",
|
||||
params: { jwt: LOGIN_TOKENS.jwt() },
|
||||
});
|
||||
|
||||
// ============== RESOLVER ==============
|
||||
|
||||
export const useLoginOptions = () => {
|
||||
return useQuery({
|
||||
queryKey: ["GetLoginOptions"],
|
||||
queryFn: () => komodo_client().auth("GetLoginOptions", {}),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUser = () => {
|
||||
const userReset = useUserReset();
|
||||
const hasJwt = !!LOGIN_TOKENS.jwt();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["GetUser"],
|
||||
queryFn: () => komodo_client().auth("GetUser", {}),
|
||||
refetchInterval: 30_000,
|
||||
enabled: hasJwt,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data && query.error) {
|
||||
userReset();
|
||||
}
|
||||
}, [query.data, query.error]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useUserInvalidate = () => {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: ["GetUser"] });
|
||||
};
|
||||
};
|
||||
|
||||
export const useUserReset = () => {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.resetQueries({ queryKey: ["GetUser"] });
|
||||
};
|
||||
};
|
||||
|
||||
export const useRead = <
|
||||
T extends Types.ReadRequest["type"],
|
||||
R extends Extract<Types.ReadRequest, { type: T }>,
|
||||
P extends R["params"],
|
||||
C extends Omit<
|
||||
UseQueryOptions<
|
||||
ReadResponses[R["type"]],
|
||||
unknown,
|
||||
ReadResponses[R["type"]],
|
||||
(T | P)[]
|
||||
>,
|
||||
"queryFn" | "queryKey"
|
||||
>,
|
||||
>(
|
||||
type: T,
|
||||
params: P,
|
||||
config?: C
|
||||
) => {
|
||||
const hasJwt = !!LOGIN_TOKENS.jwt();
|
||||
return useQuery({
|
||||
queryKey: [type, params],
|
||||
queryFn: () => komodo_client().read<T, R>(type, params),
|
||||
enabled: hasJwt && (config?.enabled !== false),
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInvalidate = () => {
|
||||
const qc = useQueryClient();
|
||||
return <
|
||||
Type extends Types.ReadRequest["type"],
|
||||
Params extends Extract<Types.ReadRequest, { type: Type }>["params"],
|
||||
>(
|
||||
...keys: Array<[Type] | [Type, Params]>
|
||||
) => keys.forEach((key) => qc.invalidateQueries({ queryKey: key }));
|
||||
};
|
||||
|
||||
export const useManageUser = <
|
||||
T extends Types.UserRequest["type"],
|
||||
R extends Extract<Types.UserRequest, { type: T }>,
|
||||
P extends R["params"],
|
||||
C extends Omit<
|
||||
UseMutationOptions<UserResponses[T], unknown, P, unknown>,
|
||||
"mutationKey" | "mutationFn"
|
||||
>,
|
||||
>(
|
||||
type: T,
|
||||
config?: C
|
||||
) => {
|
||||
const { toast } = useToast();
|
||||
return useMutation({
|
||||
mutationKey: [type],
|
||||
mutationFn: (params: P) => komodo_client().user<T, R>(type, params),
|
||||
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
|
||||
console.log("Auth error:", e);
|
||||
const msg = e.result?.error ?? "Unknown error. See console.";
|
||||
const detail = e.result?.trace
|
||||
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
|
||||
.join(" | ");
|
||||
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
|
||||
if (detail) {
|
||||
msg_log += detail + " | ";
|
||||
}
|
||||
toast({
|
||||
title: `Request ${type} Failed`,
|
||||
description: `${msg_log}See console for details`,
|
||||
variant: "destructive",
|
||||
});
|
||||
config?.onError && config.onError(e, v, c);
|
||||
},
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const useWrite = <
|
||||
T extends Types.WriteRequest["type"],
|
||||
R extends Extract<Types.WriteRequest, { type: T }>,
|
||||
P extends R["params"],
|
||||
C extends Omit<
|
||||
UseMutationOptions<WriteResponses[R["type"]], unknown, P, unknown>,
|
||||
"mutationKey" | "mutationFn"
|
||||
>,
|
||||
>(
|
||||
type: T,
|
||||
config?: C
|
||||
) => {
|
||||
const { toast } = useToast();
|
||||
return useMutation({
|
||||
mutationKey: [type],
|
||||
mutationFn: (params: P) => komodo_client().write<T, R>(type, params),
|
||||
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
|
||||
console.log("Write error:", e);
|
||||
const msg = e.result.error ?? "Unknown error. See console.";
|
||||
const detail = e.result?.trace
|
||||
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
|
||||
.join(" | ");
|
||||
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
|
||||
if (detail) {
|
||||
msg_log += detail + " | ";
|
||||
}
|
||||
toast({
|
||||
title: `Write request ${type} failed`,
|
||||
description: `${msg_log}See console for details`,
|
||||
variant: "destructive",
|
||||
});
|
||||
config?.onError && config.onError(e, v, c);
|
||||
},
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const useExecute = <
|
||||
T extends Types.ExecuteRequest["type"],
|
||||
R extends Extract<Types.ExecuteRequest, { type: T }>,
|
||||
P extends R["params"],
|
||||
C extends Omit<
|
||||
UseMutationOptions<ExecuteResponses[T], unknown, P, unknown>,
|
||||
"mutationKey" | "mutationFn"
|
||||
>,
|
||||
>(
|
||||
type: T,
|
||||
config?: C
|
||||
) => {
|
||||
const { toast } = useToast();
|
||||
return useMutation({
|
||||
mutationKey: [type],
|
||||
mutationFn: (params: P) => komodo_client().execute<T, R>(type, params),
|
||||
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
|
||||
console.log("Execute error:", e);
|
||||
const msg = e.result.error ?? "Unknown error. See console.";
|
||||
const detail = e.result?.trace
|
||||
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
|
||||
.join(" | ");
|
||||
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
|
||||
if (detail) {
|
||||
msg_log += detail + " | ";
|
||||
}
|
||||
toast({
|
||||
title: `Execute request ${type} failed`,
|
||||
description: `${msg_log}See console for details`,
|
||||
variant: "destructive",
|
||||
});
|
||||
config?.onError && config.onError(e, v, c);
|
||||
},
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAuth = <
|
||||
T extends Types.AuthRequest["type"],
|
||||
R extends Extract<Types.AuthRequest, { type: T }>,
|
||||
P extends R["params"],
|
||||
C extends Omit<
|
||||
UseMutationOptions<AuthResponses[T], unknown, P, unknown>,
|
||||
"mutationKey" | "mutationFn"
|
||||
>,
|
||||
>(
|
||||
type: T,
|
||||
config?: C
|
||||
) => {
|
||||
const { toast } = useToast();
|
||||
return useMutation({
|
||||
mutationKey: [type],
|
||||
mutationFn: (params: P) => komodo_client().auth<T, R>(type, params),
|
||||
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
|
||||
console.log("Auth error:", e);
|
||||
const msg = e.result.error ?? "Unknown error. See console.";
|
||||
const detail = e.result?.trace
|
||||
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
|
||||
.join(" | ");
|
||||
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
|
||||
if (detail) {
|
||||
msg_log += detail + " | ";
|
||||
}
|
||||
toast({
|
||||
title: `Auth request ${type} failed`,
|
||||
description: `${msg_log}See console for details`,
|
||||
variant: "destructive",
|
||||
});
|
||||
config?.onError && config.onError(e, v, c);
|
||||
},
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
// ============== UTILITY ==============
|
||||
|
||||
export const useResourceParamType = () => {
|
||||
const type = useParams().type;
|
||||
if (!type) return undefined;
|
||||
if (type === "resource-syncs") return "ResourceSync";
|
||||
return (type[0].toUpperCase() + type.slice(1, -1)) as UsableResource;
|
||||
};
|
||||
|
||||
type ResourceMap = {
|
||||
[Resource in UsableResource]: Types.ResourceListItem<unknown>[] | undefined;
|
||||
};
|
||||
|
||||
export const useAllResources = (): ResourceMap => {
|
||||
return {
|
||||
Server: useRead("ListServers", {}).data,
|
||||
Stack: useRead("ListStacks", {}).data,
|
||||
Deployment: useRead("ListDeployments", {}).data,
|
||||
Build: useRead("ListBuilds", {}).data,
|
||||
Repo: useRead("ListRepos", {}).data,
|
||||
Procedure: useRead("ListProcedures", {}).data,
|
||||
Action: useRead("ListActions", {}).data,
|
||||
Builder: useRead("ListBuilders", {}).data,
|
||||
Alerter: useRead("ListAlerters", {}).data,
|
||||
ResourceSync: useRead("ListResourceSyncs", {}).data,
|
||||
};
|
||||
};
|
||||
|
||||
// Returns true if Komodo has no resources.
|
||||
export const useNoResources = () => {
|
||||
const resources = useAllResources();
|
||||
for (const target of RESOURCE_TARGETS) {
|
||||
if (resources[target] && resources[target].length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** returns function that takes a resource target and checks if it exists */
|
||||
export const useCheckResourceExists = () => {
|
||||
const resources = useAllResources();
|
||||
return (target: Types.ResourceTarget) => {
|
||||
return (
|
||||
resources[target.type as UsableResource]?.some(
|
||||
(resource) => resource.id === target.id
|
||||
) || false
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const useFilterResources = <Info>(
|
||||
resources?: Types.ResourceListItem<Info>[],
|
||||
search?: string
|
||||
) => {
|
||||
const tags = useTagsFilter();
|
||||
const searchSplit = search?.toLowerCase()?.split(" ") || [];
|
||||
return (
|
||||
resources?.filter(
|
||||
(resource) =>
|
||||
tags.every((tag: string) => resource.tags.includes(tag)) &&
|
||||
(searchSplit.length > 0
|
||||
? searchSplit.every((search) =>
|
||||
resource.name.toLowerCase().includes(search)
|
||||
)
|
||||
: true)
|
||||
) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export const usePushRecentlyViewed = ({ type, id }: Types.ResourceTarget) => {
|
||||
const userInvalidate = useUserInvalidate();
|
||||
|
||||
const push = useManageUser("PushRecentlyViewed", {
|
||||
onSuccess: userInvalidate,
|
||||
}).mutate;
|
||||
|
||||
const exists = useRead(`List${type as UsableResource}s`, {}).data?.find(
|
||||
(r) => r.id === id
|
||||
)
|
||||
? true
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
exists && push({ resource: { type, id } });
|
||||
}, [exists, push]);
|
||||
|
||||
return () => push({ resource: { type, id } });
|
||||
};
|
||||
|
||||
export const useSetTitle = (more?: string) => {
|
||||
const info = useRead("GetCoreInfo", {}).data;
|
||||
const title = more ? `${more} | ${info?.title}` : info?.title;
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
document.title = title;
|
||||
}
|
||||
}, [title]);
|
||||
};
|
||||
|
||||
const tagsAtom = atomWithStorage<string[]>("tags-v0", []);
|
||||
|
||||
export const useTags = () => {
|
||||
const [tags, setTags] = useAtom<string[]>(tagsAtom);
|
||||
|
||||
const add_tag = (tag_id: string) => setTags([...tags, tag_id]);
|
||||
const remove_tag = (tag_id: string) =>
|
||||
setTags(tags.filter((id) => id !== tag_id));
|
||||
const toggle_tag = (tag_id: string) => {
|
||||
if (tags.includes(tag_id)) {
|
||||
remove_tag(tag_id);
|
||||
} else {
|
||||
add_tag(tag_id);
|
||||
}
|
||||
};
|
||||
const clear_tags = () => setTags([]);
|
||||
|
||||
return {
|
||||
tags,
|
||||
add_tag,
|
||||
remove_tag,
|
||||
toggle_tag,
|
||||
clear_tags,
|
||||
};
|
||||
};
|
||||
|
||||
export const useTagsFilter = () => {
|
||||
const [tags] = useAtom<string[]>(tagsAtom);
|
||||
return tags;
|
||||
};
|
||||
|
||||
export type LocalStorageSetter<T> = (state: T) => T;
|
||||
|
||||
export const useLocalStorage = <T>(
|
||||
key: string,
|
||||
init: T
|
||||
): [T, (state: T | LocalStorageSetter<T>) => void] => {
|
||||
const stored = localStorage.getItem(key);
|
||||
const parsed = stored ? (JSON.parse(stored) as T) : undefined;
|
||||
const [state, inner_set] = useState<T>(parsed ?? init);
|
||||
const set = (state: T | LocalStorageSetter<T>) => {
|
||||
inner_set((prev_state) => {
|
||||
const new_val =
|
||||
typeof state === "function"
|
||||
? (state as LocalStorageSetter<T>)(prev_state)
|
||||
: state;
|
||||
localStorage.setItem(key, JSON.stringify(new_val));
|
||||
return new_val;
|
||||
});
|
||||
};
|
||||
return [state, set];
|
||||
};
|
||||
|
||||
export const useKeyListener = (listenKey: string, onPress: () => void) => {
|
||||
useEffect(() => {
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
// This will ignore Shift + listenKey if it is sent from input / textarea
|
||||
const target = e.target as any;
|
||||
if (target.matches("input") || target.matches("textarea")) return;
|
||||
|
||||
if (e.key === listenKey) {
|
||||
e.preventDefault();
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", keydown);
|
||||
return () => document.removeEventListener("keydown", keydown);
|
||||
});
|
||||
};
|
||||
|
||||
export const useShiftKeyListener = (listenKey: string, onPress: () => void) => {
|
||||
useEffect(() => {
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
// This will ignore Shift + listenKey if it is sent from input / textarea
|
||||
const target = e.target as any;
|
||||
if (target.matches("input") || target.matches("textarea")) return;
|
||||
|
||||
if (e.shiftKey && e.key === listenKey) {
|
||||
e.preventDefault();
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", keydown);
|
||||
return () => document.removeEventListener("keydown", keydown);
|
||||
});
|
||||
};
|
||||
|
||||
/** Listens for ctrl (or CMD on mac) + the listenKey */
|
||||
export const useCtrlKeyListener = (listenKey: string, onPress: () => void) => {
|
||||
useEffect(() => {
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === listenKey) {
|
||||
e.preventDefault();
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", keydown);
|
||||
return () => document.removeEventListener("keydown", keydown);
|
||||
});
|
||||
};
|
||||
|
||||
export interface PromptHotkeysConfig {
|
||||
/** Function to call when Enter is pressed (confirm action) */
|
||||
onConfirm?: () => void;
|
||||
/** Function to call when Escape is pressed (cancel/close action) */
|
||||
onCancel?: () => void;
|
||||
/** Whether the hotkeys are enabled. Defaults to true */
|
||||
enabled?: boolean;
|
||||
/** Whether to ignore hotkeys when inside input/textarea elements. Defaults to true */
|
||||
ignoreInputs?: boolean;
|
||||
/** Whether the confirm action is disabled (e.g., form validation failed) */
|
||||
confirmDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides standard prompt/dialog hotkey behavior:
|
||||
* - Enter: Confirm/submit action
|
||||
* - Escape: Cancel/close action
|
||||
*/
|
||||
export const usePromptHotkeys = ({
|
||||
enabled = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
ignoreInputs = true,
|
||||
confirmDisabled = false,
|
||||
}: PromptHotkeysConfig) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const findConfirmButton = (): HTMLButtonElement | null => {
|
||||
const dialogContainers = document.querySelectorAll('[role="dialog"], [data-state="open"], .dialog-content');
|
||||
for (const container of dialogContainers) {
|
||||
const button = container.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
|
||||
if (button) return button;
|
||||
}
|
||||
|
||||
return document.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (ignoreInputs) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
if (onConfirm && !confirmDisabled) {
|
||||
e.preventDefault();
|
||||
const confirmButton = findConfirmButton();
|
||||
if (confirmButton) {
|
||||
confirmButton.click();
|
||||
} else {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
if (onCancel) {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, onConfirm, onCancel, ignoreInputs, confirmDisabled]);
|
||||
};
|
||||
|
||||
export type WebhookIntegration = "Github" | "Gitlab";
|
||||
export type WebhookIntegrations = {
|
||||
[key: string]: WebhookIntegration;
|
||||
};
|
||||
|
||||
const WEBHOOK_INTEGRATIONS_ATOM = atomWithStorage<WebhookIntegrations>(
|
||||
"webhook-integrations-v2",
|
||||
{}
|
||||
);
|
||||
|
||||
export const useWebhookIntegrations = () => {
|
||||
const [integrations, setIntegrations] = useAtom<WebhookIntegrations>(
|
||||
WEBHOOK_INTEGRATIONS_ATOM
|
||||
);
|
||||
return {
|
||||
integrations,
|
||||
setIntegration: (provider: string, integration: WebhookIntegration) =>
|
||||
setIntegrations({
|
||||
...integrations,
|
||||
[provider]: integration,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getWebhookIntegration = (
|
||||
integrations: WebhookIntegrations,
|
||||
git_provider: string
|
||||
) => {
|
||||
return integrations[git_provider]
|
||||
? integrations[git_provider]
|
||||
: git_provider.includes("gitlab")
|
||||
? "Gitlab"
|
||||
: "Github";
|
||||
};
|
||||
|
||||
export type WebhookIdOrName = "Id" | "Name";
|
||||
|
||||
const WEBHOOK_ID_OR_NAME_ATOM = atomWithStorage<WebhookIdOrName>(
|
||||
"webhook-id-or-name-v1",
|
||||
"Id"
|
||||
);
|
||||
|
||||
export const useWebhookIdOrName = () => {
|
||||
return useAtom<WebhookIdOrName>(WEBHOOK_ID_OR_NAME_ATOM);
|
||||
};
|
||||
|
||||
export type Dimensions = { width: number; height: number };
|
||||
export const useWindowDimensions = () => {
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
const callback = () => {
|
||||
setDimensions({
|
||||
width: window.screen.availWidth,
|
||||
height: window.screen.availHeight,
|
||||
});
|
||||
};
|
||||
callback();
|
||||
window.addEventListener("resize", callback);
|
||||
return () => {
|
||||
window.removeEventListener("resize", callback);
|
||||
};
|
||||
}, []);
|
||||
return dimensions;
|
||||
};
|
||||
|
||||
const selected_resources = atomFamily((_: UsableResource) =>
|
||||
atom<string[]>([])
|
||||
);
|
||||
export const useSelectedResources = (type: UsableResource) =>
|
||||
useAtom(selected_resources(type));
|
||||
|
||||
const filter_by_update_available = atomWithStorage<boolean>(
|
||||
"update-available-filter-v1",
|
||||
false
|
||||
);
|
||||
export const useFilterByUpdateAvailable: () => [boolean, () => void] = () => {
|
||||
const [filter, set] = useAtom<boolean>(filter_by_update_available);
|
||||
return [filter, () => set(!filter)];
|
||||
};
|
||||
|
||||
export const usePermissions = ({ type, id }: Types.ResourceTarget) => {
|
||||
const user = useUser().data;
|
||||
const perms = useRead("GetPermission", { target: { type, id } }).data as
|
||||
| Types.PermissionLevelAndSpecifics
|
||||
| Types.PermissionLevel
|
||||
| undefined;
|
||||
const info = useRead("GetCoreInfo", {}).data;
|
||||
const ui_write_disabled = info?.ui_write_disabled ?? false;
|
||||
const disable_non_admin_create = info?.disable_non_admin_create ?? false;
|
||||
|
||||
const level =
|
||||
(perms && typeof perms === "string" ? perms : perms?.level) ??
|
||||
Types.PermissionLevel.None;
|
||||
const specific =
|
||||
(perms && typeof perms === "string" ? [] : perms?.specific) ?? [];
|
||||
|
||||
const canWrite = !ui_write_disabled && level === Types.PermissionLevel.Write;
|
||||
const canExecute = has_minimum_permissions(
|
||||
{ level, specific },
|
||||
Types.PermissionLevel.Execute
|
||||
);
|
||||
|
||||
const [
|
||||
specificLogs,
|
||||
specificInspect,
|
||||
specificTerminal,
|
||||
specificAttach,
|
||||
specificProcesses,
|
||||
] = [
|
||||
specific.includes(Types.SpecificPermission.Logs),
|
||||
specific.includes(Types.SpecificPermission.Inspect),
|
||||
specific.includes(Types.SpecificPermission.Terminal),
|
||||
specific.includes(Types.SpecificPermission.Attach),
|
||||
specific.includes(Types.SpecificPermission.Processes),
|
||||
];
|
||||
|
||||
const canCreate =
|
||||
type === "Server"
|
||||
? user?.admin ||
|
||||
(!disable_non_admin_create && user?.create_server_permissions)
|
||||
: type === "Build"
|
||||
? user?.admin ||
|
||||
(!disable_non_admin_create && user?.create_build_permissions)
|
||||
: type === "Alerter" ||
|
||||
type === "Builder" ||
|
||||
type === "Procedure" ||
|
||||
type === "Action"
|
||||
? user?.admin
|
||||
: user?.admin || !disable_non_admin_create;
|
||||
|
||||
return {
|
||||
canWrite,
|
||||
canExecute,
|
||||
canCreate,
|
||||
specific,
|
||||
specificLogs,
|
||||
specificInspect,
|
||||
specificTerminal,
|
||||
specificAttach,
|
||||
specificProcesses,
|
||||
};
|
||||
};
|
||||
|
||||
const templatesQueryBehaviorAtom =
|
||||
atomWithStorage<Types.TemplatesQueryBehavior>(
|
||||
"templates-query-behavior-v0",
|
||||
Types.TemplatesQueryBehavior.Exclude
|
||||
);
|
||||
|
||||
export const useTemplatesQueryBehavior = () =>
|
||||
useAtom<Types.TemplatesQueryBehavior>(templatesQueryBehaviorAtom);
|
||||
|
||||
export type SettingsView =
|
||||
| "Variables"
|
||||
| "Tags"
|
||||
| "Providers"
|
||||
| "Users"
|
||||
| "Profile";
|
||||
|
||||
const viewAtom = atomWithStorage<SettingsView>("settings-view-v2", "Variables");
|
||||
|
||||
export const useSettingsView = () => useAtom<SettingsView>(viewAtom);
|
||||
|
||||
/**
|
||||
* Map of unique host ports to array of formatted full port map spec
|
||||
* Formatted ex: 0.0.0.0:3000:3000/tcp
|
||||
*/
|
||||
export type PortsMap = { [host_port: string]: Array<Types.Port> };
|
||||
|
||||
export const useContainerPortsMap = (ports: Types.Port[]) => {
|
||||
return useMemo(() => {
|
||||
const map: PortsMap = {};
|
||||
for (const port of ports) {
|
||||
if (!port.PublicPort || !port.PrivatePort) continue;
|
||||
if (map[port.PublicPort]) {
|
||||
map[port.PublicPort].push(port);
|
||||
} else {
|
||||
map[port.PublicPort] = [port];
|
||||
}
|
||||
}
|
||||
for (const key in map) {
|
||||
map[key].sort();
|
||||
}
|
||||
return map;
|
||||
}, [ports]);
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom React hook that debounces a value, delaying its update until after
|
||||
* a specified period of inactivity. This is useful for performance optimization
|
||||
* in scenarios like search inputs, form validation, or API calls.
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
Reference in New Issue
Block a user