🎉 Add MCP server to integrations section in dashboard

This commit is contained in:
Luis de Dios
2026-01-21 20:41:09 +01:00
parent c1335961b4
commit bdb2ca841a
11 changed files with 368 additions and 298 deletions

View File

@@ -47,6 +47,7 @@
(def schema:props
[:map {:title "ProfileProps"}
[:plugins {:optional true} schema:plugin-registry]
[:mcp-status {:optional true} ::sm/boolean]
[:newsletter-updates {:optional true} ::sm/boolean]
[:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid]

View File

@@ -197,7 +197,7 @@
:settings-options
:settings-feedback
:settings-subscription
:settings-access-tokens
:settings-integrations
:settings-notifications)
(let [params (get params :query)
error-report-id (some-> params :error-report-id uuid/parse*)]

View File

@@ -18,6 +18,7 @@ $sz-32: px2rem(32);
$sz-36: px2rem(36);
$sz-40: px2rem(40);
$sz-48: px2rem(48);
$sz-64: px2rem(64);
$sz-88: px2rem(88);
$sz-96: px2rem(96);
$sz-120: px2rem(120);

View File

@@ -36,7 +36,7 @@
["/feedback" :settings-feedback]
["/options" :settings-options]
["/subscriptions" :settings-subscription]
["/access-tokens" :settings-access-tokens]
["/integrations" :settings-integrations]
["/notifications" :settings-notifications]]
["/frame-preview" :frame-preview]

View File

@@ -13,10 +13,10 @@
[app.main.store :as st]
[app.main.ui.hooks :as hooks]
[app.main.ui.modal :refer [modal-container*]]
[app.main.ui.settings.access-tokens :refer [access-tokens-page]]
[app.main.ui.settings.change-email]
[app.main.ui.settings.delete-account]
[app.main.ui.settings.feedback :refer [feedback-page*]]
[app.main.ui.settings.integrations :refer [integrations-page*]]
[app.main.ui.settings.notifications :refer [notifications-page*]]
[app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.password :refer [password-page]]
@@ -73,8 +73,8 @@
:settings-subscription
[:> subscription-page* {:profile profile}]
:settings-access-tokens
[:& access-tokens-page]
:settings-integrations
[:> integrations-page*]
:settings-notifications
[:& notifications-page* {:profile profile}])]]]]))

View File

@@ -1,202 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
// ACCESS TOKENS PAGE
.dashboard-access-tokens {
display: grid;
grid-template-rows: auto 1fr;
margin: deprecated.$s-80 auto deprecated.$s-120 auto;
gap: deprecated.$s-32;
width: deprecated.$s-800;
}
// hero
.access-tokens-hero {
display: grid;
grid-template-rows: auto auto 1fr;
gap: deprecated.$s-32;
width: deprecated.$s-500;
font-size: deprecated.$fs-14;
margin: deprecated.$s-16 auto 0 auto;
}
.hero-title {
@include deprecated.bigTitleTipography;
color: var(--title-foreground-color-hover);
}
.hero-desc {
color: var(--title-foreground-color);
margin-bottom: 0;
font-size: deprecated.$fs-14;
}
.hero-btn {
@extend .button-primary;
}
// table empty
.access-tokens-empty {
display: grid;
place-items: center;
align-content: center;
height: deprecated.$s-156;
max-width: deprecated.$s-1000;
width: 100%;
padding: deprecated.$s-32;
border: deprecated.$s-1 solid var(--panel-border-color);
border-radius: deprecated.$br-8;
color: var(--dashboard-list-text-foreground-color);
}
// Access tokens table
.dashboard-table {
height: fit-content;
}
.table-rows {
display: grid;
grid-auto-rows: deprecated.$s-64;
gap: deprecated.$s-16;
width: 100%;
height: 100%;
max-width: deprecated.$s-1000;
margin-top: deprecated.$s-16;
color: var(--title-foreground-color);
}
.table-row {
display: grid;
grid-template-columns: 43% 1fr auto;
align-items: center;
height: deprecated.$s-64;
width: 100%;
padding: 0 deprecated.$s-16;
border-radius: deprecated.$br-8;
background-color: var(--dashboard-list-background-color);
color: var(--dashboard-list-foreground-color);
}
.field-name {
@include deprecated.textEllipsis;
display: grid;
width: 43%;
min-width: deprecated.$s-300;
}
.expiration-date {
@include deprecated.flexCenter;
min-width: deprecated.$s-76;
width: fit-content;
height: deprecated.$s-24;
border-radius: deprecated.$br-8;
color: var(--dashboard-list-text-foreground-color);
}
.expired {
@include deprecated.headlineSmallTypography;
padding: 0 deprecated.$s-6;
color: var(--pill-foreground-color);
background-color: var(--status-widget-background-color-warning);
}
.actions {
position: relative;
}
.menu-icon {
@extend .button-icon;
stroke: var(--icon-foreground);
}
.menu-btn {
@include deprecated.buttonStyle;
}
// Create access token modal
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
min-width: deprecated.$s-408;
}
.modal-header {
margin-bottom: deprecated.$s-24;
}
.modal-title {
@include deprecated.uppercaseTitleTipography;
color: var(--modal-title-foreground-color);
}
.modal-close-btn {
@extend .modal-close-btn-base;
}
.modal-content {
@include deprecated.flexColumn;
gap: deprecated.$s-24;
@include deprecated.bodySmallTypography;
margin-bottom: deprecated.$s-24;
}
.select-title {
@include deprecated.bodySmallTypography;
color: var(--modal-title-foreground-color);
}
.custon-input-wrapper {
@include deprecated.flexRow;
border-radius: deprecated.$br-8;
height: deprecated.$s-32;
background-color: var(--input-background-color);
}
.custom-input-token {
@extend .input-element;
@include deprecated.bodySmallTypography;
margin: 0;
flex-grow: 1;
&:focus {
outline: none;
border: deprecated.$s-1 solid var(--input-border-color-active);
}
}
.token-value {
@include deprecated.textEllipsis;
@include deprecated.bodySmallTypography;
flex-grow: 1;
}
.copy-btn {
@include deprecated.flexCenter;
@extend .button-secondary;
height: deprecated.$s-28;
width: deprecated.$s-28;
}
.clipboard-icon {
@extend .button-icon-small;
}
.token-created-info {
color: var(--modal-text-foreground-color);
}
.action-buttons {
@extend .modal-action-btns;
button {
@extend .modal-accept-btn;
}
}
.cancel-button {
@extend .modal-cancel-btn;
}

View File

@@ -4,18 +4,27 @@
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.settings.access-tokens
(ns app.main.ui.settings.integrations
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.profile :as du]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
@@ -23,15 +32,6 @@
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private clipboard-icon
(deprecated-icon/icon-xref :clipboard (stl/css :clipboard-icon)))
(def ^:private close-icon
(deprecated-icon/icon-xref :close (stl/css :close-icon)))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(def tokens-ref
(l/derived :access-tokens st/state))
@@ -46,7 +46,7 @@
(def initial-data
{:name "" :expiration-date "never"})
(mf/defc access-token-modal
(mf/defc access-token-modal*
{::mf/register modal/components
::mf/register-as :access-token}
[]
@@ -109,10 +109,10 @@
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} (tr "modals.create-access-token.title")]
[:button {:class (stl/css :modal-close-btn)
:on-click on-close}
close-icon]]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close
:icon i/close}]]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :fields-row)}
@@ -149,10 +149,10 @@
:value (:token created "")
:class (stl/css :custom-input-token)
:read-only true}]
[:button {:title (tr "modals.create-access-token.copy-token")
:class (stl/css :copy-btn)
:on-click copy-token}
clipboard-icon]])
[:> icon-button* {:variant "secondary"
:aria-label (tr "modals.create-access-token.copy-token")
:on-click copy-token
:icon i/clipboard}]])
#_(when @created?
[:button {:class (stl/css :copy-btn)
:title (tr "modals.create-access-token.copy-token")
@@ -165,30 +165,38 @@
[:div {:class (stl/css :action-buttons)}
(if @created?
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.close")
:on-click modal/hide!}]
[:> button* {:variant "secondary"
:on-click modal/hide!}
(tr "labels.close")]
[:*
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.cancel")
:on-click modal/hide!}]
[:> fm/submit-button*
{:large? false :label (tr "modals.create-access-token.submit-label")}]])]]]]]))
[:> button* {:variant "secondary"
:on-click modal/hide!}
(tr "labels.cancel")]
[:> fm/submit-button* {:large? false
:label (tr "modals.create-access-token.submit-label")}]])]]]]]))
(mf/defc access-tokens-hero
(mf/defc access-tokens*
{::mf/private true}
[]
(let [on-click (mf/use-fn #(st/emit! (modal/show :access-token {})))]
[:div {:class (stl/css :access-tokens-hero)}
[:h2 {:class (stl/css :hero-title)} (tr "dashboard.access-tokens.personal")]
[:p {:class (stl/css :hero-desc)} (tr "dashboard.access-tokens.personal.description")]
(let [on-click
(mf/use-fn
#(st/emit! (modal/show :access-token {})))]
[:button {:class (stl/css :hero-btn)
:on-click on-click}
[:div {:class (stl/css :access-tokens-hero)}
[:> heading* {:level 2
:typography t/title-medium
:class (stl/css :mcp-title)}
(tr "dashboard.access-tokens.personal")]
[:> text* {:as "p"
:typography t/body-medium
:class (stl/css :mcp-description)}
(tr "dashboard.access-tokens.personal.description")]
[:> button* {:variant "primary"
:on-click on-click}
(tr "dashboard.access-tokens.create")]]))
(mf/defc access-token-actions
(mf/defc token-actions*
{::mf/private true}
[{:keys [on-delete]}]
(let [local (mf/use-state {:menu-open false})
show? (:menu-open @local)
@@ -200,7 +208,8 @@
menu-ref (mf/use-ref)
on-menu-close
(mf/use-fn #(swap! local assoc :menu-open false))
(mf/use-fn
#(swap! local assoc :menu-open false))
on-menu-click
(mf/use-fn
@@ -216,24 +225,27 @@
(dom/stop-propagation event)
(on-menu-click event))))]
[:button {:class (stl/css :menu-btn)
:tab-index "0"
:ref menu-ref
:on-click on-menu-click
:on-key-down on-keydown}
menu-icon
[:> context-menu*
{:on-close on-menu-close
:show show?
:fixed true
:min-width true
:top "auto"
:left "auto"
:options options}]]))
[:*
[:> icon-button* {:variant "ghost"
:class (stl/css :item-button)
:ref menu-ref
:aria-pressed show?
:aria-label (tr "labels.options")
:on-click on-menu-click
:on-key-down on-keydown
:icon i/menu}]
[:> context-menu* {:on-close on-menu-close
:show show?
:fixed true
:min-width true
:top "auto"
:left "auto"
:options options}]]))
(mf/defc access-token-item
{::mf/wrap [mf/memo]}
[{:keys [token] :as props}]
(mf/defc token-item*
{::mf/private true
::mf/wrap [mf/memo]}
[{:keys [token]}]
(let [expires-at (:expires-at token)
expires-txt (some-> expires-at (ct/format-inst "PPP"))
expired? (and (some? expires-at) (> (ct/now) expires-at))
@@ -250,42 +262,106 @@
(mf/use-fn
(mf/deps delete-fn)
(fn []
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-acces-token.title")
:message (tr "modals.delete-acces-token.message")
:accept-label (tr "modals.delete-acces-token.accept")
:on-accept delete-fn}))))]
(st/emit! (modal/show {:type :confirm
:title (tr "modals.delete-acces-token.title")
:message (tr "modals.delete-acces-token.message")
:accept-label (tr "modals.delete-acces-token.accept")
:on-accept delete-fn}))))]
[:div {:class (stl/css :table-row)}
[:div {:class (stl/css :table-field :field-name)}
(str (:name token))]
[:div {:class (stl/css :item)}
[:> text* {:as "div"
:typography t/body-medium
:title (:name token)
:class (stl/css :item-title)}
(:name token)]
[:div {:class (stl/css-case :expiration-date true
:expired expired?)}
[:> text* {:as "div"
:typography t/body-small
:class (stl/css-case :item-subtitle true
:warning expired?)}
(cond
(nil? expires-at) (tr "dashboard.access-tokens.no-expiration")
expired? (tr "dashboard.access-tokens.expired-on" expires-txt)
:else (tr "dashboard.access-tokens.expires-on" expires-txt))]
[:div {:class (stl/css :table-field :actions)}
[:& access-token-actions
{:on-delete on-delete}]]]))
expired? (tr "dashboard.access-tokens.expired-on" expires-txt)
:else (tr "dashboard.access-tokens.expires-on" expires-txt))]
(mf/defc access-tokens-page
[:div {:class (stl/css :table-field :actions)}
[:> token-actions* {:on-delete on-delete}]]]))
(mf/defc mcp-config*
{::mf/private true}
[]
(let [profile (mf/deref refs/profile)
active? (d/nilv (-> profile :props :mcp-status) false)
handle-mcp-status-change
(mf/use-fn
(fn [value]
(st/emit! (du/update-profile-props {:mcp-status value}))))]
[:div {:class (stl/css :mcp)}
[:div
[:> heading* {:level 2
:typography t/title-medium
:class (stl/css :mcp-title)}
"MCP Server"]
[:> text* {:as "p"
:typography t/body-medium
:class (stl/css :mcp-description)}
"The Penpot MCP Server enables MCP clients to interact directly with Penpot design files."]]
[:div
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :mcp-headline)}
"Status"]
[:> switch* {:label (if active? "Enabled" "Disabled")
:default-checked active?
:on-change handle-mcp-status-change}]]
[:div
[:> text* {:as "h3"
:typography t/headline-small
:class (stl/css :mcp-headline)}
"MCP keys"]
[:> button* {:variant "primary"}
"Regenerate MCP key"]]]))
(mf/defc integrations-page*
[]
(let [tokens (mf/deref tokens-ref)]
(mf/with-effect []
(dom/set-html-title (tr "title.settings.access-tokens"))
(st/emit! (du/fetch-access-tokens)))
[:div {:class (stl/css :dashboard-access-tokens)}
[:& access-tokens-hero]
(if (empty? tokens)
[:div {:class (stl/css :access-tokens-empty)}
[:div (tr "dashboard.access-tokens.empty.no-access-tokens")]
[:div (tr "dashboard.access-tokens.empty.add-one")]]
[:div {:class (stl/css :dashboard-table)}
[:div {:class (stl/css :table-rows)}
(for [token tokens]
[:& access-token-item {:token token :key (:id token)}])]])]))
[:div {:class (stl/css :integrations)}
[:> heading* {:level 1
:typography t/title-large
:class (stl/css :integrations-title)}
"Integrations"]
(when (contains? cf/flags :mcp-server)
[:> mcp-config*])
(when (contains? cf/flags :access-tokens)
[:*
[:> access-tokens*]
(if (empty? tokens)
[:div {:class (stl/css :frame)}
[:> text* {:as "div"
:typography t/body-medium
:class (stl/css :frame-text-center)}
[:div (tr "dashboard.access-tokens.empty.no-access-tokens")]
[:div (tr "dashboard.access-tokens.empty.add-one")]]]
[:div {:class (stl/css :list)}
(for [token tokens]
[:> token-item* {:key (:id token)
:token token}])])])]))

View File

@@ -0,0 +1,185 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/mixins.scss" as *;
.integrations {
display: grid;
grid-template-rows: auto 1fr;
margin: deprecated.$s-80 auto deprecated.$s-120 auto;
gap: deprecated.$s-32;
width: deprecated.$s-500;
}
.integrations-title {
color: var(--color-foreground-primary);
}
.mcp {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.mcp-title {
color: var(--color-foreground-primary);
margin: var(--sp-s) 0;
}
.mcp-headline {
color: var(--color-foreground-primary);
}
.mcp-description {
color: var(--color-foreground-secondary);
}
.separator {
border: 1px solid var(--color-background-quaternary);
}
.frame {
border: 1px solid var(--color-background-quaternary);
padding: var(--sp-m);
border-radius: $br-8;
}
.frame-text-center {
text-align: center;
color: var(--color-foreground-secondary);
}
.list {
display: grid;
grid-auto-rows: $sz-64;
gap: var(--sp-m);
}
.item {
display: grid;
grid-template-columns: 40% 1fr auto;
align-items: center;
background-color: var(--color-background-tertiary);
border-radius: $br-8;
}
.item-title {
@include textEllipsis;
align-content: center;
block-size: $sz-64;
padding: 0 var(--sp-l);
color: var(--color-foreground-primary);
}
.item-subtitle {
align-content: center;
block-size: $sz-64;
padding: 0 var(--sp-l);
color: var(--color-foreground-secondary);
&.warning {
padding: 0 var(--sp-m);
block-size: $sz-32;
inline-size: fit-content;
color: var(--pill-foreground-color);
background-color: var(--color-background-warning);
border: 1px solid var(--color-accent-warning);
border-radius: $br-8;
}
}
.item-button {
block-size: $sz-64;
inline-size: $sz-48;
}
// Create access token modal
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
@extend .modal-container-base;
min-width: deprecated.$s-408;
}
.modal-header {
margin-bottom: deprecated.$s-24;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
@include deprecated.uppercaseTitleTipography;
color: var(--modal-title-foreground-color);
}
.modal-content {
@include deprecated.flexColumn;
gap: deprecated.$s-24;
@include deprecated.bodySmallTypography;
margin-bottom: deprecated.$s-24;
}
// hero
.access-tokens-hero {
display: grid;
grid-template-rows: auto auto 1fr;
gap: deprecated.$s-32;
width: deprecated.$s-500;
font-size: deprecated.$fs-14;
margin: deprecated.$s-16 auto 0 auto;
}
.actions {
position: relative;
}
.action-buttons {
display: flex;
justify-content: right;
}
.select-title {
@include deprecated.bodySmallTypography;
color: var(--modal-title-foreground-color);
}
.custon-input-wrapper {
@include deprecated.flexRow;
border-radius: deprecated.$br-8;
height: deprecated.$s-32;
background-color: var(--input-background-color);
}
.custom-input-token {
@extend .input-element;
@include deprecated.bodySmallTypography;
margin: 0;
flex-grow: 1;
&:focus {
outline: none;
border: deprecated.$s-1 solid var(--input-border-color-active);
}
}
.token-value {
@include deprecated.textEllipsis;
@include deprecated.bodySmallTypography;
flex-grow: 1;
}
.token-created-info {
color: var(--modal-text-foreground-color);
}

View File

@@ -43,8 +43,8 @@
(def ^:private go-settings-subscription
#(st/emit! (rt/nav :settings-subscription)))
(def ^:private go-settings-access-tokens
#(st/emit! (rt/nav :settings-access-tokens)))
(def ^:private go-settings-integrations
#(st/emit! (rt/nav :settings-integrations)))
(def ^:private go-settings-notifications
#(st/emit! (rt/nav :settings-notifications)))
@@ -66,7 +66,7 @@
options? (= section :settings-options)
feedback? (= section :settings-feedback)
subscription? (= section :settings-subscription)
access-tokens? (= section :settings-access-tokens)
integrations? (= section :settings-integrations)
notifications? (= section :settings-notifications)
team-id (or (dtm/get-last-team-id)
(:default-team-id profile))
@@ -115,12 +115,13 @@
:data-testid "settings-subscription"}
[:span {:class (stl/css :element-title)} (tr "subscription.labels")]])
(when (contains? cf/flags :access-tokens)
[:li {:class (stl/css-case :current access-tokens?
(when (or (contains? cf/flags :access-tokens)
(contains? cf/flags :mcp-server))
[:li {:class (stl/css-case :current integrations?
:settings-item true)
:on-click go-settings-access-tokens
:data-testid "settings-access-tokens"}
[:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]])
:on-click go-settings-integrations
:data-testid "settings-integrations"}
[:span {:class (stl/css :element-title)} (tr "labels.integrations")]])
[:hr {:class (stl/css :sidebar-separator)}]

View File

@@ -2468,6 +2468,10 @@ msgstr "Info"
msgid "labels.installed-fonts"
msgstr "Installed fonts"
#: src/app/main/ui/settings/sidebar.cljs:123
msgid "labels.integrations"
msgstr "Integrations"
#: src/app/main/ui/static.cljs:396
msgid "labels.internal-error.desc-message-first"
msgstr "Something bad happened."

View File

@@ -2439,6 +2439,10 @@ msgstr "Información"
msgid "labels.installed-fonts"
msgstr "Fuentes instaladas"
#: src/app/main/ui/settings/sidebar.cljs:123
msgid "labels.integrations"
msgstr "Integraciones"
#: src/app/main/ui/static.cljs:396
msgid "labels.internal-error.desc-message-first"
msgstr "Ha ocurrido algo extraño."