forked from github-starred/komodo
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3d8e603ec | ||
|
|
8b5c179473 | ||
|
|
8582bc92da |
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
130
bin/core/src/api/write/user.rs
Normal file
130
bin/core/src/api/write/user.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
##
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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)) ?? []}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user