Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .beads/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Database
*.db
*.db-journal
*.db-shm
*.db-wal

# Lock files
*.lock

# Temporary
last-touched
*.tmp

# Local history backups
.br_history/

# Sync state (local-only, per-machine)
.sync.lock
sync_base.jsonl

# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json

# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
sync-state.json

# Worktree redirect file
redirect

# bv (beads viewer) lock file
.bv.lock
4 changes: 4 additions & 0 deletions .beads/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Beads Project Configuration
# issue_prefix: xplane
# default_priority: 2
# default_type: task
17 changes: 17 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .beads/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,6 @@ build/
.react-router/
temp/
scripts/

# bv (beads viewer) local config and caches
.bv/
198 changes: 198 additions & 0 deletions apps/admin/app/(all)/(dashboard)/authentication/keycloak/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
import { useForm } from "react-hook-form";
// plane internal packages
import { API_BASE_URL } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceKeycloakAuthenticationConfigurationKeys } from "@plane/types";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
import type { TControllerInputFormField } from "@/components/common/controller-input";
import { ControllerInput } from "@/components/common/controller-input";
import type { TControllerSwitchFormField } from "@/components/common/controller-switch";
import { ControllerSwitch } from "@/components/common/controller-switch";
import type { TCopyField } from "@/components/common/copy-field";
import { CopyField } from "@/components/common/copy-field";
// hooks
import { useInstance } from "@/hooks/store";

type Props = {
config: IFormattedInstanceConfiguration;
};

type KeycloakConfigFormValues = Record<TInstanceKeycloakAuthenticationConfigurationKeys, string>;

export function InstanceKeycloakConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<KeycloakConfigFormValues>({
defaultValues: {
KEYCLOAK_HOST: config["KEYCLOAK_HOST"] || "",
KEYCLOAK_REALM: config["KEYCLOAK_REALM"] || "",
KEYCLOAK_CLIENT_ID: config["KEYCLOAK_CLIENT_ID"],
KEYCLOAK_CLIENT_SECRET: config["KEYCLOAK_CLIENT_SECRET"],
ENABLE_KEYCLOAK_SYNC: config["ENABLE_KEYCLOAK_SYNC"] || "0",
},
});

const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";

const KEYCLOAK_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "KEYCLOAK_HOST",
type: "text",
label: "Keycloak Host",
placeholder: "https://keycloak.example.com",
error: Boolean(errors.KEYCLOAK_HOST),
required: true,
},
{
key: "KEYCLOAK_REALM",
type: "text",
label: "Realm",
description: <>The Keycloak realm to authenticate against.</>,
placeholder: "master",
error: Boolean(errors.KEYCLOAK_REALM),
required: true,
},
{
key: "KEYCLOAK_CLIENT_ID",
type: "text",
label: "Client ID",
description: <>You will get this from your Keycloak admin console.</>,
placeholder: "plane",
error: Boolean(errors.KEYCLOAK_CLIENT_ID),
required: true,
},
{
key: "KEYCLOAK_CLIENT_SECRET",
type: "password",
label: "Client Secret",
description: <>Your client secret is found in your Keycloak admin console.</>,
placeholder: "••••••••••••••••••••••••••••••••",
error: Boolean(errors.KEYCLOAK_CLIENT_SECRET),
required: true,
},
];

const KEYCLOAK_FORM_SWITCH_FIELD: TControllerSwitchFormField<KeycloakConfigFormValues> = {
name: "ENABLE_KEYCLOAK_SYNC",
label: "Keycloak",
};

const KEYCLOAK_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/keycloak/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Valid Redirect URIs</CodeBlock> field
in your Keycloak client settings.
</>
),
},
];

const onSubmit = async (formData: KeycloakConfigFormValues) => {
const payload: Partial<KeycloakConfigFormValues> = { ...formData };

try {
const response = await updateInstanceConfigurations(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your Keycloak authentication is configured. You should test it now.",
});
reset({
KEYCLOAK_HOST: response.find((item) => item.key === "KEYCLOAK_HOST")?.value,
KEYCLOAK_REALM: response.find((item) => item.key === "KEYCLOAK_REALM")?.value,
KEYCLOAK_CLIENT_ID: response.find((item) => item.key === "KEYCLOAK_CLIENT_ID")?.value,
KEYCLOAK_CLIENT_SECRET: response.find((item) => item.key === "KEYCLOAK_CLIENT_SECRET")?.value,
ENABLE_KEYCLOAK_SYNC: response.find((item) => item.key === "ENABLE_KEYCLOAK_SYNC")?.value,
});
} catch (err) {
console.error(err);
}
};

const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};

return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">Keycloak-provided details for Plane</div>
{KEYCLOAK_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<ControllerSwitch control={control} field={KEYCLOAK_FORM_SWITCH_FIELD} />
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button
variant="primary"
size="lg"
onClick={(e) => void handleSubmit(onSubmit)(e)}
loading={isSubmitting}
disabled={!isDirty}
>
{isSubmitting ? "Saving" : "Save changes"}
</Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for Keycloak</div>
{KEYCLOAK_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
}
105 changes: 105 additions & 0 deletions apps/admin/app/(all)/(dashboard)/authentication/keycloak/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// assets
import keycloakLogo from "@/app/assets/logos/keycloak-logo.svg?url";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { useInstance } from "@/hooks/store";
// types
import type { Route } from "./+types/page";
// local
import { InstanceKeycloakConfigForm } from "./form";

const InstanceKeycloakAuthenticationPage = observer(function InstanceKeycloakAuthenticationPage() {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// config
const enableKeycloakConfig = formattedConfig?.IS_KEYCLOAK_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());

const updateConfig = async (key: "IS_KEYCLOAK_ENABLED", value: string) => {
setIsSubmitting(true);

const payload = {
[key]: value,
};

const updateConfigPromise = updateInstanceConfigurations(payload);

setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration",
success: {
title: "Configuration saved",
message: () => `Keycloak authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});

await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};

const isKeycloakEnabled = enableKeycloakConfig === "1";

return (
<PageWrapper
customHeader={
<AuthenticationMethodCard
name="Keycloak"
description="Allow members to login or sign up to plane with their Keycloak accounts."
icon={<img src={keycloakLogo} height={24} width={24} alt="Keycloak Logo" />}
config={
<ToggleSwitch
value={isKeycloakEnabled}
onChange={() => {
updateConfig("IS_KEYCLOAK_ENABLED", isKeycloakEnabled ? "0" : "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
}
>
{formattedConfig ? (
<InstanceKeycloakConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</PageWrapper>
);
});
export const meta: Route.MetaFunction = () => [{ title: "Keycloak Authentication - God Mode" }];

export default InstanceKeycloakAuthenticationPage;
9 changes: 9 additions & 0 deletions apps/admin/app/assets/logos/keycloak-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/admin/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default [
route("authentication/gitlab", "./(all)/(dashboard)/authentication/gitlab/page.tsx"),
route("authentication/google", "./(all)/(dashboard)/authentication/google/page.tsx"),
route("authentication/gitea", "./(all)/(dashboard)/authentication/gitea/page.tsx"),
route("authentication/keycloak", "./(all)/(dashboard)/authentication/keycloak/page.tsx"),
route("ai", "./(all)/(dashboard)/ai/page.tsx"),
route("image", "./(all)/(dashboard)/image/page.tsx"),
]),
Expand Down
Loading