Compare commits

...

3 Commits

Author SHA1 Message Date
Maxwell Becker
e3d8e603ec 1.15.5 (#116)
* 1.15.5
- Update your user's username and password
- **Admin**: Delete Users

* update username / password / delete user backend

* bump version

* alerter default disabled

* delete users and update username / password

* set password "" after update
2024-10-11 19:42:43 -07:00
mbecker20
8b5c179473 account recover note 2024-10-11 19:16:01 -04:00
mbecker20
8582bc92da fix Destroy Before Deploy config 2024-10-10 04:17:17 -04:00
26 changed files with 706 additions and 193 deletions

26
Cargo.lock generated
View File

@@ -41,7 +41,7 @@ dependencies = [
[[package]]
name = "alerter"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"axum",
@@ -943,7 +943,7 @@ dependencies = [
[[package]]
name = "command"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"komodo_client",
"run_command",
@@ -1355,7 +1355,7 @@ dependencies = [
[[package]]
name = "environment_file"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"thiserror",
]
@@ -1439,7 +1439,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"serror",
]
@@ -1571,7 +1571,7 @@ checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
[[package]]
name = "git"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"command",
@@ -2192,7 +2192,7 @@ dependencies = [
[[package]]
name = "komodo_cli"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"clap",
@@ -2208,7 +2208,7 @@ dependencies = [
[[package]]
name = "komodo_client"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2239,7 +2239,7 @@ dependencies = [
[[package]]
name = "komodo_core"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2296,7 +2296,7 @@ dependencies = [
[[package]]
name = "komodo_periphery"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2383,7 +2383,7 @@ dependencies = [
[[package]]
name = "logger"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"komodo_client",
@@ -2447,7 +2447,7 @@ dependencies = [
[[package]]
name = "migrator"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"dotenvy",
@@ -3102,7 +3102,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "periphery_client"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"komodo_client",
@@ -4880,7 +4880,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "update_logger"
version = "1.15.4"
version = "1.15.5"
dependencies = [
"anyhow",
"komodo_client",

View File

@@ -3,7 +3,7 @@ resolver = "2"
members = ["bin/*", "lib/*", "client/core/rs", "client/periphery/rs"]
[workspace.package]
version = "1.15.4"
version = "1.15.5"
edition = "2021"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"

View File

@@ -28,6 +28,7 @@ mod service_user;
mod stack;
mod sync;
mod tag;
mod user;
mod user_group;
mod variable;
@@ -40,6 +41,11 @@ mod variable;
#[resolver_args(User)]
#[serde(tag = "type", content = "params")]
pub enum WriteRequest {
// ==== USER ====
UpdateUserUsername(UpdateUserUsername),
UpdateUserPassword(UpdateUserPassword),
DeleteUser(DeleteUser),
// ==== SERVICE USER ====
CreateServiceUser(CreateServiceUser),
UpdateServiceUserDescription(UpdateServiceUserDescription),

View File

@@ -0,0 +1,130 @@
use std::str::FromStr;
use anyhow::{anyhow, Context};
use komodo_client::{
api::write::{
DeleteUser, DeleteUserResponse, UpdateUserPassword,
UpdateUserPasswordResponse, UpdateUserUsername,
UpdateUserUsernameResponse,
},
entities::{
user::{User, UserConfig},
NoData,
},
};
use mungos::mongodb::bson::{doc, oid::ObjectId};
use resolver_api::Resolve;
use crate::{
helpers::hash_password,
state::{db_client, State},
};
//
impl Resolve<UpdateUserUsername, User> for State {
async fn resolve(
&self,
UpdateUserUsername { username }: UpdateUserUsername,
user: User,
) -> anyhow::Result<UpdateUserUsernameResponse> {
if username.is_empty() {
return Err(anyhow!("Username cannot be empty."));
}
let db = db_client();
if db
.users
.find_one(doc! { "username": &username })
.await
.context("Failed to query for existing users")?
.is_some()
{
return Err(anyhow!("Username already taken."));
}
let id = ObjectId::from_str(&user.id)
.context("User id not valid ObjectId.")?;
db.users
.update_one(
doc! { "_id": id },
doc! { "$set": { "username": username } },
)
.await
.context("Failed to update user username on database.")?;
Ok(NoData {})
}
}
//
impl Resolve<UpdateUserPassword, User> for State {
async fn resolve(
&self,
UpdateUserPassword { password }: UpdateUserPassword,
user: User,
) -> anyhow::Result<UpdateUserPasswordResponse> {
let UserConfig::Local { .. } = user.config else {
return Err(anyhow!("User is not local user"));
};
if password.is_empty() {
return Err(anyhow!("Password cannot be empty."));
}
let id = ObjectId::from_str(&user.id)
.context("User id not valid ObjectId.")?;
let hashed_password = hash_password(password)?;
db_client()
.users
.update_one(
doc! { "_id": id },
doc! { "$set": {
"config.data.password": hashed_password
} },
)
.await
.context("Failed to update user password on database.")?;
Ok(NoData {})
}
}
//
impl Resolve<DeleteUser, User> for State {
async fn resolve(
&self,
DeleteUser { user }: DeleteUser,
admin: User,
) -> anyhow::Result<DeleteUserResponse> {
if !admin.admin {
return Err(anyhow!("Calling user is not admin."));
}
if admin.username == user || admin.id == user {
return Err(anyhow!("User cannot delete themselves."));
}
let query = if let Ok(id) = ObjectId::from_str(&user) {
doc! { "_id": id }
} else {
doc! { "username": user }
};
let db = db_client();
let Some(user) = db
.users
.find_one(query.clone())
.await
.context("Failed to query database for users.")?
else {
return Err(anyhow!("No user found with given id / username"));
};
if user.super_admin {
return Err(anyhow!("Cannot delete a super admin user."));
}
if user.admin && !admin.super_admin {
return Err(anyhow!(
"Only a Super Admin can delete an admin user."
));
}
db.users
.delete_one(query)
.await
.context("Failed to delete user from database")?;
Ok(user)
}
}

View File

@@ -16,12 +16,10 @@ use resolver_api::Resolve;
use crate::{
config::core_config,
state::State,
state::{db_client, jwt_client},
helpers::hash_password,
state::{db_client, jwt_client, State},
};
const BCRYPT_COST: u32 = 10;
impl Resolve<CreateLocalUser, HeaderMap> for State {
#[instrument(name = "CreateLocalUser", skip(self))]
async fn resolve(
@@ -47,8 +45,7 @@ impl Resolve<CreateLocalUser, HeaderMap> for State {
return Err(anyhow!("Password cannot be empty string"));
}
let password = bcrypt::hash(password, BCRYPT_COST)
.context("failed to hash password")?;
let hashed_password = hash_password(password)?;
let no_users_exist =
db_client().users.find_one(Document::new()).await?.is_none();
@@ -71,7 +68,9 @@ impl Resolve<CreateLocalUser, HeaderMap> for State {
last_update_view: 0,
recents: Default::default(),
all: Default::default(),
config: UserConfig::Local { password },
config: UserConfig::Local {
password: hashed_password,
},
};
let user_id = db_client()

View File

@@ -66,6 +66,15 @@ pub fn random_string(length: usize) -> String {
.collect()
}
const BCRYPT_COST: u32 = 10;
pub fn hash_password<P>(password: P) -> anyhow::Result<String>
where
P: AsRef<[u8]>,
{
bcrypt::hash(password, BCRYPT_COST)
.context("failed to hash password")
}
/// First checks db for token, then checks core config.
/// Only errors if db call errors.
/// Returns (token, use_https)

View File

@@ -3,12 +3,69 @@ use resolver_api::derive::Request;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::user::User;
use crate::entities::{user::User, NoData};
use super::KomodoWriteRequest;
//
/// **Only for local users**. Update the calling users username.
/// Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Request, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(UpdateUserUsernameResponse)]
pub struct UpdateUserUsername {
pub username: String,
}
#[typeshare]
pub type UpdateUserUsernameResponse = NoData;
//
/// **Only for local users**. Update the calling users password.
/// Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Request, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(UpdateUserPasswordResponse)]
pub struct UpdateUserPassword {
pub password: String,
}
#[typeshare]
pub type UpdateUserPasswordResponse = NoData;
//
/// **Admin only**. Delete a user.
/// Admins can delete any non-admin user.
/// Only Super Admin can delete an admin.
/// No users can delete a Super Admin user.
/// User cannot delete themselves.
/// Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Request, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(DeleteUserResponse)]
pub struct DeleteUser {
/// User id or username
#[serde(alias = "username", alias = "id")]
pub user: String,
}
#[typeshare]
pub type DeleteUserResponse = User;
//
/// **Admin only.** Create a service user.
/// Response: [User].
#[typeshare]

View File

@@ -37,9 +37,8 @@ pub type _PartialAlerterConfig = PartialAlerterConfig;
#[partial(skip_serializing_none, from, diff)]
pub struct AlerterConfig {
/// Whether the alerter is enabled
#[serde(default = "default_enabled")]
#[builder(default = "default_enabled()")]
#[partial_default(default_enabled())]
#[serde(default)]
#[builder(default)]
pub enabled: bool,
/// Where to route the alert messages.
@@ -73,14 +72,10 @@ impl AlerterConfig {
}
}
fn default_enabled() -> bool {
true
}
impl Default for AlerterConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
enabled: Default::default(),
endpoint: Default::default(),
alert_types: Default::default(),
resources: Default::default(),

View File

@@ -172,6 +172,11 @@ export type ReadResponses = {
};
export type WriteResponses = {
// ==== USER ====
UpdateUserUsername: Types.UpdateUserUsername;
UpdateUserPassword: Types.UpdateUserPassword;
DeleteUser: Types.DeleteUser;
// ==== SERVICE USER ====
CreateServiceUser: Types.CreateServiceUserResponse;
UpdateServiceUserDescription: Types.UpdateServiceUserDescriptionResponse;

View File

@@ -3116,6 +3116,12 @@ export type DeleteSyncWebhookResponse = NoData;
export type UpdateTagsOnResourceResponse = NoData;
export type UpdateUserUsernameResponse = NoData;
export type UpdateUserPasswordResponse = NoData;
export type DeleteUserResponse = User;
export type CreateServiceUserResponse = User;
export type UpdateServiceUserDescriptionResponse = User;
@@ -6074,6 +6080,35 @@ export interface UpdateTagsOnResource {
tags: string[];
}
/**
* **Only for local users**. Update the calling users username.
* Response: [NoData].
*/
export interface UpdateUserUsername {
username: string;
}
/**
* **Only for local users**. Update the calling users password.
* Response: [NoData].
*/
export interface UpdateUserPassword {
password: string;
}
/**
* **Admin only**. Delete a user.
* Admins can delete any non-admin user.
* Only Super Admin can delete an admin.
* No users can delete a Super Admin user.
* User cannot delete themselves.
* Response: [NoData].
*/
export interface DeleteUser {
/** User id or username */
user: string;
}
/**
* **Admin only.** Create a service user.
* Response: [User].
@@ -6694,6 +6729,9 @@ export type UserRequest =
| { type: "DeleteApiKey", params: DeleteApiKey };
export type WriteRequest =
| { type: "UpdateUserUsername", params: UpdateUserUsername }
| { type: "UpdateUserPassword", params: UpdateUserPassword }
| { type: "DeleteUser", params: DeleteUser }
| { type: "CreateServiceUser", params: CreateServiceUser }
| { type: "UpdateServiceUserDescription", params: UpdateServiceUserDescription }
| { type: "CreateApiKeyForServiceUser", params: CreateApiKeyForServiceUser }

View File

@@ -74,7 +74,7 @@ repo_directory = "/repo-cache"
##
## NOTE:
## Komodo has no API to recover account logins, but if this happens you can doctor the database using Mongo Compass.
## Create a new user, login to the database with Compass, note down your old users username and _id.
## Create a new Komodo user (Sign Up button), login to the database with Compass, note down your old users username and _id.
## Then delete the old user, and update the new user to have the same username and _id.
## Make sure to set `enabled: true` and maybe `admin: true` on the new user as well, while using Compass.
##

View File

@@ -28,7 +28,11 @@ export const KeysTable = ({
}) => {
return (
<div className="flex items-center gap-2">
<Input className="w-40" value={key} disabled />
<Input
className="w-[100px] lg:w-[200px] overflow-ellipsis"
value={key}
disabled
/>
<CopyButton content={key} />
</div>
);

View File

@@ -163,6 +163,7 @@ export const PageXlRow = ({
interface SectionProps {
title?: ReactNode;
icon?: ReactNode;
titleRight?: ReactNode;
titleOther?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
@@ -173,6 +174,7 @@ interface SectionProps {
export const Section = ({
title,
icon,
titleRight,
titleOther,
actions,
children,
@@ -189,6 +191,7 @@ export const Section = ({
<div className="px-2 flex items-center gap-2 text-muted-foreground">
{icon}
{title && <h2 className="text-xl">{title}</h2>}
{titleRight}
</div>
) : (
titleOther
@@ -234,7 +237,7 @@ export const NewLayout = ({
<div className="flex flex-col gap-4 py-8">{children}</div>
<DialogFooter>
<Button
variant="outline"
variant="secondary"
onClick={async () => {
setLoading(true);
await onConfirm();

View File

@@ -393,7 +393,7 @@ export const StackConfig = ({
label: "Destroy",
labelHidden: true,
components: {
run_build: {
destroy_before_deploy: {
label: "Destroy Before Deploy",
description:
"Ensure 'docker compose down' is run before redeploying the Stack.",

View File

@@ -53,7 +53,7 @@ export const NewServiceUser = () => {
<div className="grid md:grid-cols-2">
Username
<Input
placeholder="user-group-name"
placeholder="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>

View File

@@ -80,7 +80,11 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={() => onOpenChange(false)}>
<Button
variant="secondary"
className="gap-4"
onClick={() => onOpenChange(false)}
>
Confirm <Check className="w-4" />
</Button>
</DialogFooter>
@@ -103,7 +107,10 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => {
Expiry
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-36 justify-between px-3">
<Button
variant="outline"
className="w-36 justify-between px-3"
>
{expires}
</Button>
</DropdownMenuTrigger>
@@ -125,7 +132,12 @@ export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={submit} disabled={isPending}>
<Button
variant="secondary"
className="gap-4"
onClick={submit}
disabled={isPending}
>
Submit
{isPending ? (
<Loader2 className="w-4 animate-spin" />

View File

@@ -5,21 +5,30 @@ import { useNavigate } from "react-router-dom";
import { ColumnDef } from "@tanstack/react-table";
import { MinusCircle } from "lucide-react";
import { ConfirmButton } from "@components/util";
import { useUser } from "@lib/hooks";
export const UserTable = ({
users,
onUserRemove,
onUserDelete,
userDeleteDisabled,
onSelfClick,
}: {
users: Types.User[];
onUserRemove?: (user_id: string) => void;
onUserDelete?: (user_id: string) => void;
userDeleteDisabled?: (user_id: string) => boolean;
onSelfClick?: () => void;
}) => {
const user = useUser().data;
const nav = useNavigate();
const columns: ColumnDef<Types.User, "User" | "Admin">[] = [
const columns: ColumnDef<Types.User, "User" | "Admin" | "Super Admin">[] = [
{ header: "Username", accessorKey: "username" },
{ header: "Type", accessorKey: "config.type" },
{
header: "Level",
accessorFn: (user) => (user.admin ? "Admin" : "User"),
accessorFn: (user) =>
user.admin ? (user.super_admin ? "Super Admin" : "Admin") : "User",
},
{
header: "Enabled",
@@ -51,12 +60,37 @@ export const UserTable = ({
),
});
}
if (onUserDelete) {
columns.push({
header: "Delete",
cell: ({ row }) => (
<ConfirmButton
title="Delete"
variant="destructive"
icon={<MinusCircle className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();
onUserDelete(row.original._id?.$oid!);
}}
disabled={
row.original._id?.$oid
? userDeleteDisabled?.(row.original._id.$oid) ?? true
: true
}
/>
),
});
}
return (
<DataTable
tableKey="users"
data={users}
columns={columns}
onRowClick={(user) => nav(`/users/${user._id!.$oid}`)}
onRowClick={(row) =>
row._id?.$oid === user?._id?.$oid
? onSelfClick?.()
: nav(`/users/${row._id!.$oid}`)
}
/>
);
};

View File

@@ -1,18 +1,18 @@
import { useLocalStorage, useUser } from "@lib/hooks";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { CreateVariable, Variables } from "./variables";
import { CreateTag, Tags } from "./tags";
import { Variables } from "./variables";
import { Tags } from "./tags";
import { UsersPage } from "./users";
import { CreateKey, Keys } from "./keys";
import { Profile } from "./profile";
import { Page } from "@components/layouts";
import { useState } from "react";
import { Input } from "@ui/input";
import { ProvidersPage } from "./providers";
import { ExportButton } from "@components/export";
export const Settings = () => {
const user = useUser().data;
const [view, setView] = useLocalStorage("settings-view-v0", "Variables");
const [search, setSearch] = useState("");
const [view, setView] = useLocalStorage<
"Variables" | "Tags" | "Providers" | "Users" | "Profile"
>("settings-view-v1", "Variables");
const currentView =
(view === "Users" || view === "Providers") && !user?.admin
? "Variables"
@@ -21,7 +21,7 @@ export const Settings = () => {
<Page>
<Tabs
value={currentView}
onValueChange={setView}
onValueChange={setView as any}
className="flex flex-col gap-6"
>
<div className="flex items-center justify-between">
@@ -32,20 +32,10 @@ export const Settings = () => {
<TabsTrigger value="Providers">Providers</TabsTrigger>
)}
{user?.admin && <TabsTrigger value="Users">Users</TabsTrigger>}
<TabsTrigger value="Api Keys">Api Keys</TabsTrigger>
<TabsTrigger value="Profile">Profile</TabsTrigger>
</TabsList>
{currentView === "Variables" && <CreateVariable />}
{currentView === "Tags" && <CreateTag />}
{currentView === "Api Keys" && <CreateKey />}
{currentView === "Users" && (
<Input
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[250px]"
/>
)}
{currentView === "Variables" && <ExportButton include_variables />}
</div>
<TabsContent value="Variables">
@@ -61,11 +51,11 @@ export const Settings = () => {
)}
{user?.admin && (
<TabsContent value="Users">
<UsersPage search={search} />
<UsersPage goToProfile={() => setView("Profile")} />
</TabsContent>
)}
<TabsContent value="Api Keys">
<Keys />
<TabsContent value="Profile">
<Profile />
</TabsContent>
</Tabs>
</Page>

View File

@@ -1,5 +1,12 @@
import { ConfirmButton, CopyButton } from "@components/util";
import { useInvalidate, useManageUser, useRead, useSetTitle } from "@lib/hooks";
import {
useInvalidate,
useManageUser,
useRead,
useSetTitle,
useUser,
useWrite,
} from "@lib/hooks";
import {
Dialog,
DialogContent,
@@ -10,7 +17,17 @@ import {
} from "@ui/dialog";
import { Button } from "@ui/button";
import { useToast } from "@ui/use-toast";
import { Trash, PlusCircle, Loader2, Check } from "lucide-react";
import {
Trash,
PlusCircle,
Loader2,
Check,
User,
Eye,
EyeOff,
KeyRound,
UserPen,
} from "lucide-react";
import { useState } from "react";
import { Input } from "@ui/input";
import {
@@ -21,18 +38,143 @@ import {
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { KeysTable } from "@components/keys/table";
import { Section } from "@components/layouts";
import { Card, CardHeader } from "@ui/card";
import { Types } from "@komodo/client";
export const Keys = () => {
useSetTitle("Api Keys");
export const Profile = () => {
useSetTitle("Profile");
const user = useUser().data;
if (!user) {
return (
<div className="w-full h-[400px] flex justify-center items-center">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
return <ProfileInner user={user} />;
};
const ProfileInner = ({ user }: { user: Types.User }) => {
const { refetch: refetchUser } = useUser();
const { toast } = useToast();
const keys = useRead("ListApiKeys", {}).data ?? [];
return <KeysTable keys={keys} DeleteKey={DeleteKey} />;
const [username, setUsername] = useState(user.username);
const [password, setPassword] = useState("");
const [hidePassword, setHidePassword] = useState(true);
const { mutate: updateUsername } = useWrite("UpdateUserUsername", {
onSuccess: () => {
toast({ title: "Username updated." });
refetchUser();
},
});
const { mutate: updatePassword } = useWrite("UpdateUserPassword", {
onSuccess: () => {
toast({ title: "Password updated." });
setPassword("");
},
});
return (
<div className="flex flex-col gap-6">
{/* Profile */}
<Section title="Profile" icon={<User className="w-4 h-4" />}>
<Card>
<CardHeader className="gap-4">
{/* Profile Info */}
<UserProfile user={user} />
{/* Update Username */}
<div className="flex items-center gap-4">
<div className="text-muted-foreground font-mono">Username:</div>
<div className="w-[200px] lg:w-[300px]">
<Input
placeholder="Input username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<ConfirmButton
title="Update Username"
icon={<UserPen className="w-4 h-4" />}
onClick={() => updateUsername({ username })}
disabled={!username || username === user.username}
/>
</div>
{/* Update Password */}
{user.config.type === "Local" && (
<div className="flex items-center gap-4">
<div className="text-muted-foreground font-mono">Password:</div>
<div className="w-[200px] lg:w-[300px] flex items-center gap-2">
<Input
placeholder="Input password"
type={hidePassword ? "password" : "text"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
size="icon"
variant="outline"
onClick={() => setHidePassword((curr) => !curr)}
>
{hidePassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
<ConfirmButton
title="Update Password"
icon={<UserPen className="w-4 h-4" />}
onClick={() => updatePassword({ password })}
disabled={!password}
/>
</div>
)}
</CardHeader>
</Card>
</Section>
{/* Api Keys */}
<Section title="Api Keys" icon={<KeyRound className="w-4 h-4" />}>
<div>
<CreateKey />
</div>
<KeysTable keys={keys} DeleteKey={DeleteKey} />
</Section>
</div>
);
};
const UserProfile = ({ user }: { user: Types.User }) => {
return (
<div className="flex items-center gap-4 flex-wrap">
<div className="font-mono text-muted-foreground">Type:</div>
{user.config.type}
<div className="font-mono text-muted-foreground">|</div>
<div className="font-mono text-muted-foreground">Admin:</div>
{user.admin ? "True" : "False"}
{user.admin && (
<>
<div className="font-mono text-muted-foreground">|</div>
<div className="font-mono text-muted-foreground">Super Admin:</div>
{user.super_admin ? "True" : "False"}
</>
)}
</div>
);
};
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
type ExpiresOptions = "90 days" | "180 days" | "1 year" | "never";
export const CreateKey = () => {
const CreateKey = () => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [expires, setExpires] = useState<ExpiresOptions>("never");
@@ -86,7 +228,11 @@ export const CreateKey = () => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={() => onOpenChange(false)}>
<Button
variant="secondary"
className="gap-4"
onClick={() => onOpenChange(false)}
>
Confirm <Check className="w-4" />
</Button>
</DialogFooter>
@@ -109,7 +255,10 @@ export const CreateKey = () => {
Expiry
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-36 justify-between px-3">
<Button
className="w-36 justify-between px-3"
variant="outline"
>
{expires}
</Button>
</DropdownMenuTrigger>
@@ -131,7 +280,12 @@ export const CreateKey = () => {
</div>
</div>
<DialogFooter className="flex justify-end">
<Button className="gap-4" onClick={submit} disabled={isPending}>
<Button
variant="secondary"
className="gap-4"
onClick={submit}
disabled={isPending}
>
Submit
{isPending ? (
<Loader2 className="w-4 animate-spin" />
@@ -162,6 +316,7 @@ const DeleteKey = ({ api_key }: { api_key: string }) => {
return (
<ConfirmButton
title="Delete"
variant="destructive"
icon={<Trash className="w-4 h-4" />}
onClick={(e) => {
e.stopPropagation();

View File

@@ -21,12 +21,21 @@ import {
import { Input } from "@ui/input";
import { Switch } from "@ui/switch";
import { useToast } from "@ui/use-toast";
import { Check, Loader2, PlusCircle, Trash } from "lucide-react";
import {
Check,
GitBranch,
HardDrive,
Loader2,
PlusCircle,
Search,
Trash,
} from "lucide-react";
import { ChangeEvent, ReactNode, useState } from "react";
import { Section } from "@components/layouts";
export const ProvidersPage = () => {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
<Providers type="GitProvider" />
<Providers type="DockerRegistry" />
</div>
@@ -70,42 +79,31 @@ const Providers = ({ type }: { type: "GitProvider" | "DockerRegistry" }) => {
},
});
return (
<div className="flex flex-col gap-4">
<Section
title={type === "DockerRegistry" ? "Registry Accounts" : "Git Accounts"}
icon={
type === "DockerRegistry" ? (
<HardDrive className="w-4 h-4" />
) : (
<GitBranch className="w-4 h-4" />
)
}
>
{/* Create / Search */}
<div className="flex items-center justify-between">
<h2>
{type === "DockerRegistry" ? "Registry Accounts" : "Git Accounts"}
</h2>
<div className="flex items-center gap-2">
<CreateAccount type={type} />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
className="pl-8 w-[200px] lg:w-[300px]"
/>
<CreateAccount type={type} />
</div>
</div>
{updateMenuData && (
<TextUpdateMenu
title={updateMenuData.title}
titleRight={updateMenuData.titleRight}
placeholder={updateMenuData.placeholder}
value={updateMenuData.value}
onUpdate={updateMenuData.onUpdate}
triggerClassName="w-full"
disabled={disabled}
open={!!updateMenuData}
setOpen={(open) => {
if (!open) {
setUpdateMenuData(false);
}
}}
triggerHidden
/>
)}
{/** ACCOUNTS */}
{/* ACCOUNTS */}
<DataTable
tableKey={type + "-accounts"}
data={filtered}
@@ -234,17 +232,25 @@ const Providers = ({ type }: { type: "GitProvider" | "DockerRegistry" }) => {
},
]}
/>
{/** SECRETS */}
{/* {secrets.length && (
<div className="flex items-center gap-2 text-muted-foreground">
<div>Core Secrets:</div>
{secrets.map((secret) => (
<Badge variant="secondary">{secret}</Badge>
))}
</div>
)} */}
</div>
{updateMenuData && (
<TextUpdateMenu
title={updateMenuData.title}
titleRight={updateMenuData.titleRight}
placeholder={updateMenuData.placeholder}
value={updateMenuData.value}
onUpdate={updateMenuData.onUpdate}
triggerClassName="w-full"
disabled={disabled}
open={!!updateMenuData}
setOpen={(open) => {
if (!open) {
setUpdateMenuData(false);
}
}}
triggerHidden
/>
)}
</Section>
);
};

View File

@@ -11,7 +11,7 @@ import {
import { Button } from "@ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/card";
import { useToast } from "@ui/use-toast";
import { Trash, PlusCircle, Loader2, Check } from "lucide-react";
import { Trash, PlusCircle, Loader2, Check, Search } from "lucide-react";
import { useState } from "react";
import { Input } from "@ui/input";
import { UpdateUser } from "@components/updates/details";
@@ -26,12 +26,18 @@ export const Tags = () => {
return (
<div className="flex flex-col gap-4">
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
/>
<div className="flex items-center justify-between">
<CreateTag />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
<DataTable
tableKey="tags"
data={tags?.filter((tag) => tag.name.includes(search)) ?? []}

View File

@@ -2,68 +2,132 @@ import { ExportButton } from "@components/export";
import { Section } from "@components/layouts";
import { NewServiceUser, NewUserGroup } from "@components/users/new";
import { UserTable } from "@components/users/table";
import { useRead, useSetTitle } from "@lib/hooks";
import {
useInvalidate,
useRead,
useSetTitle,
useUser,
useWrite,
} from "@lib/hooks";
import { DeleteUserGroup } from "@pages/user-group";
import { DataTable } from "@ui/data-table";
import { User, Users } from "lucide-react";
import { Input } from "@ui/input";
import { useToast } from "@ui/use-toast";
import { Search, User, Users } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export const UsersPage = ({ search }: { search: string }) => {
export const UsersPage = ({ goToProfile }: { goToProfile: () => void }) => {
useSetTitle("Users");
const nav = useNavigate();
const groups = useRead("ListUserGroups", {}).data;
const users = useRead("ListUsers", {}).data;
const searchSplit = search.split(" ");
return (
<div className="flex flex-col gap-6">
{/* User Groups */}
<Section
title="User Groups"
icon={<Users className="w-4 h-4" />}
actions={
<div className="flex items-center gap-4">
{groups && groups.length ? (
<UserGroupsSection />
<UsersSection goToProfile={goToProfile} />
</div>
);
};
const UserGroupsSection = () => {
const nav = useNavigate();
const groups = useRead("ListUserGroups", {}).data;
const [search, setSearch] = useState("");
const searchSplit = search.split(" ");
return (
<Section title="User Groups" icon={<Users className="w-4 h-4" />}>
<div className="flex items-center justify-between">
<NewUserGroup />
<div className="flex items-center gap-4">
{groups && groups.length > 0 && (
<div className="flex items-center gap-4">
<ExportButton
user_groups={groups
?.map((group) => group._id?.$oid!)
.filter((id) => id)}
/>
) : undefined}
<NewUserGroup />
</div>
)}
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
</div>
<DataTable
tableKey="user-groups"
data={
groups?.filter((group) =>
searchSplit.every((term) => group.name.includes(term))
) ?? []
}
>
<DataTable
tableKey="user-groups"
data={
groups?.filter((group) =>
searchSplit.every((term) => group.name.includes(term))
) ?? []
}
columns={[
{ header: "Name", accessorKey: "name" },
{
header: "Members",
accessorFn: (group) => group.users.length,
},
]}
onRowClick={(group) => nav(`/user-groups/${group._id!.$oid}`)}
/>
</Section>
{/* Users */}
<Section
title="Users"
icon={<User className="w-4 h-4" />}
actions={<NewServiceUser />}
>
<UserTable
users={
users?.filter((user) =>
searchSplit.every((term) => user.username.includes(term))
) ?? []
}
/>
</Section>
</div>
columns={[
{ header: "Name", accessorKey: "name" },
{
header: "Members",
accessorFn: (group) => group.users.length,
},
{
header: "Delete",
cell: ({ row: { original: group } }) => (
<DeleteUserGroup group={group} />
),
},
]}
onRowClick={(group) => nav(`/user-groups/${group._id!.$oid}`)}
/>
</Section>
);
};
const UsersSection = ({ goToProfile }: { goToProfile: () => void }) => {
const user = useUser().data;
const inv = useInvalidate();
const { toast } = useToast();
const { mutate: deleteUser } = useWrite("DeleteUser", {
onSuccess: () => {
toast({ title: "User deleted." });
inv(["ListUsers"]);
},
});
const users = useRead("ListUsers", {}).data;
const [search, setSearch] = useState("");
const searchSplit = search.split(" ");
return (
<Section title="Users" icon={<User className="w-4 h-4" />}>
<div className="flex items-center justify-between">
<NewServiceUser />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
<UserTable
users={
users?.filter((user) =>
searchSplit.every((term) => user.username.includes(term))
) ?? []
}
onUserDelete={
user?.admin ? (user_id) => deleteUser({ user: user_id }) : undefined
}
userDeleteDisabled={(user_id) => {
const toDelete = users?.find((user) => user._id?.$oid === user_id);
if (!toDelete) return true;
if (!toDelete.admin) return false;
if (toDelete.super_admin) return true;
return !user?.super_admin;
}}
onSelfClick={goToProfile}
/>
</Section>
);
};

View File

@@ -1,4 +1,3 @@
import { ExportButton } from "@components/export";
import { ConfirmButton, CopyButton, TextUpdateMenu } from "@components/util";
import {
useInvalidate,
@@ -22,7 +21,7 @@ import {
import { Input } from "@ui/input";
import { Switch } from "@ui/switch";
import { useToast } from "@ui/use-toast";
import { Check, Loader2, PlusCircle, Trash } from "lucide-react";
import { Check, Loader2, PlusCircle, Search, Trash } from "lucide-react";
import { useState } from "react";
export const Variables = () => {
@@ -71,14 +70,17 @@ export const Variables = () => {
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[200px] lg:w-[300px]"
/>
<ExportButton include_variables />
<div className="flex justify-between gap-4">
<CreateVariable />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
placeholder="search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
{updateMenuData && (
@@ -101,6 +103,7 @@ export const Variables = () => {
{/** VARIABLES */}
<div className="max-w-full overflow-auto">
{/* <div className="w-full min-w-[1200px]"> */}
<DataTable
tableKey="variables"
data={filtered}
@@ -196,11 +199,12 @@ export const Variables = () => {
},
{
header: "Delete",
size: 200,
cell: ({ row }) => <DeleteVariable name={row.original.name} />,
minSize: 200,
},
]}
/>
{/* </div> */}
</div>
{/** SECRETS */}
@@ -216,7 +220,7 @@ export const Variables = () => {
);
};
export const CreateVariable = () => {
const CreateVariable = () => {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");

View File

@@ -206,7 +206,7 @@ const UserAvatar = ({ avatar }: { avatar: string | undefined }) =>
<User className="w-4" />
);
const DeleteUserGroup = ({ group }: { group: Types.UserGroup }) => {
export const DeleteUserGroup = ({ group }: { group: Types.UserGroup }) => {
const nav = useNavigate();
const inv = useInvalidate();
const { toast } = useToast();

View File

@@ -136,7 +136,7 @@ const ApiKeysTable = ({ user_id }: { user_id: string }) => {
const keys = useRead("ListApiKeysForServiceUser", { user: user_id }).data;
return (
<Card>
<CardHeader className="border-b pb-6 flex justify-between">
<CardHeader className="border-b pb-6 flex flex-row items-center gap-4">
Api Keys <CreateKeyForServiceUser user_id={user_id} />
</CardHeader>
<CardContent>

View File

@@ -73,14 +73,13 @@ export function DataTable<TData, TValue>({
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const size = header.column.getSize();
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
"relative whitespace-nowrap bg-background border-b border-r last:border-r-0"
// `w-[${header.column.getSize()}px]`
)}
className="relative whitespace-nowrap bg-background border-b border-r last:border-r-0"
style={{ width: `${size}px` }}
>
{header.isPlaceholder
? null
@@ -111,11 +110,8 @@ export function DataTable<TData, TValue>({
return (
<TableCell
key={cell.id}
// className="p-4 border-x first:border-r first:border-l-0 last:border-l last:border-r-0"
className={cn(
"p-4 overflow-hidden overflow-ellipsis",
size && `w-[${size}px]`
)}
className="p-4 overflow-hidden overflow-ellipsis"
style={{ width: `${size}px` }}
>
{flexRender(
cell.column.columnDef.cell,