Skip to content

Commit 87b7537

Browse files
rodrimaiabpmct
andauthored
feat: add license settings UI (#7210)
* wip: license page * WIP * WIP * wip * wip * wip * wip * wip * wip * Apply suggestions from code review Co-authored-by: Ben Potter <ben@coder.com> * wip: ui improvements * wip: extract components * wip: stories * wip: stories * fixes from PR reviews * fix stories * fix empty license page * fix copy * fix * wip * add golang test --------- Co-authored-by: Ben Potter <ben@coder.com>
1 parent c3fe251 commit 87b7537

File tree

17 files changed

+1082
-163
lines changed

17 files changed

+1082
-163
lines changed

enterprise/coderd/license/license.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ func Entitlements(
5353
return entitlements, xerrors.Errorf("query active user count: %w", err)
5454
}
5555

56+
// always shows active user count regardless of license
57+
entitlements.Features[codersdk.FeatureUserLimit] = codersdk.Feature{
58+
Entitlement: codersdk.EntitlementNotEntitled,
59+
Enabled: enablements[codersdk.FeatureUserLimit],
60+
Actual: &activeUserCount,
61+
}
62+
5663
allFeatures := false
5764

5865
// Here we loop through licenses to detect enabled features.

enterprise/coderd/license/license_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ func TestEntitlements(t *testing.T) {
3737
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
3838
}
3939
})
40+
t.Run("Always return the current user count", func(t *testing.T) {
41+
t.Parallel()
42+
db := dbfake.New()
43+
entitlements, err := license.Entitlements(context.Background(), db, slog.Logger{}, 1, 1, coderdenttest.Keys, all)
44+
require.NoError(t, err)
45+
require.False(t, entitlements.HasLicense)
46+
require.False(t, entitlements.Trial)
47+
require.Equal(t, *entitlements.Features[codersdk.FeatureUserLimit].Actual, int64(0))
48+
})
4049
t.Run("SingleLicenseNothing", func(t *testing.T) {
4150
t.Parallel()
4251
db := dbfake.New()

site/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@
6868
"react": "18.2.0",
6969
"react-chartjs-2": "4.3.1",
7070
"react-color": "2.19.3",
71+
"react-confetti": "^6.1.0",
7172
"react-dom": "18.2.0",
7273
"react-headless-tabs": "6.0.3",
7374
"react-helmet-async": "1.3.0",
7475
"react-i18next": "12.1.1",
7576
"react-markdown": "8.0.3",
7677
"react-router-dom": "6.4.1",
7778
"react-syntax-highlighter": "15.5.0",
79+
"react-use": "^17.4.0",
7880
"react-virtualized-auto-sizer": "1.0.7",
7981
"react-window": "1.8.8",
8082
"remark-gfm": "3.0.1",

site/src/AppRouter.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ const TemplateSchedulePage = lazy(
156156
),
157157
)
158158

159+
const LicensesSettingsPage = lazy(
160+
() =>
161+
import(
162+
"./pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPage"
163+
),
164+
)
165+
const AddNewLicensePage = lazy(
166+
() =>
167+
import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"),
168+
)
169+
159170
export const AppRouter: FC = () => {
160171
return (
161172
<Suspense fallback={<FullScreenLoader />}>
@@ -244,6 +255,8 @@ export const AppRouter: FC = () => {
244255
element={<DeploySettingsLayout />}
245256
>
246257
<Route path="general" element={<GeneralSettingsPage />} />
258+
<Route path="licenses" element={<LicensesSettingsPage />} />
259+
<Route path="licenses/add" element={<AddNewLicensePage />} />
247260
<Route path="security" element={<SecuritySettingsPage />} />
248261
<Route path="appearance" element={<AppearanceSettingsPage />} />
249262
<Route path="network" element={<NetworkSettingsPage />} />

site/src/api/api.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,37 @@ export const getWorkspaceBuildParameters = async (
965965
)
966966
return response.data
967967
}
968+
type Claims = {
969+
license_expires?: jwt.NumericDate
970+
account_type?: string
971+
account_id?: string
972+
trial: boolean
973+
all_features: boolean
974+
version: number
975+
features: Record<string, number>
976+
require_telemetry?: boolean
977+
}
978+
979+
export type GetLicensesResponse = Omit<TypesGen.License, "claims"> & {
980+
claims: Claims
981+
expires_at: string
982+
}
983+
984+
export const getLicenses = async (): Promise<GetLicensesResponse[]> => {
985+
const response = await axios.get(`/api/v2/licenses`)
986+
return response.data
987+
}
988+
989+
export const createLicense = async (
990+
data: TypesGen.AddLicenseRequest,
991+
): Promise<TypesGen.AddLicenseRequest> => {
992+
const response = await axios.post(`/api/v2/licenses`, data)
993+
return response.data
994+
}
995+
996+
export const removeLicense = async (licenseId: number): Promise<void> => {
997+
await axios.delete(`/api/v2/licenses/${licenseId}`)
998+
}
968999

9691000
export class MissingBuildParameters extends Error {
9701001
parameters: TypesGen.TemplateVersionParameter[] = []

site/src/components/DeploySettingsLayout/Sidebar.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { makeStyles } from "@material-ui/core/styles"
22
import Brush from "@material-ui/icons/Brush"
33
import LaunchOutlined from "@material-ui/icons/LaunchOutlined"
4+
import ApprovalIcon from "@material-ui/icons/VerifiedUserOutlined"
45
import LockRounded from "@material-ui/icons/LockOutlined"
56
import Globe from "@material-ui/icons/PublicOutlined"
67
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
@@ -48,6 +49,12 @@ export const Sidebar: React.FC = () => {
4849
>
4950
General
5051
</SidebarNavItem>
52+
<SidebarNavItem
53+
href="licenses"
54+
icon={<SidebarNavItemIcon icon={ApprovalIcon} />}
55+
>
56+
Licenses
57+
</SidebarNavItem>
5158
<SidebarNavItem
5259
href="appearance"
5360
icon={<SidebarNavItemIcon icon={Brush} />}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import { Stack } from "components/Stack/Stack"
3+
import { FC, DragEvent, useRef, ReactNode } from "react"
4+
import UploadIcon from "@material-ui/icons/CloudUploadOutlined"
5+
import { useClickable } from "hooks/useClickable"
6+
import CircularProgress from "@material-ui/core/CircularProgress"
7+
import { combineClasses } from "utils/combineClasses"
8+
import IconButton from "@material-ui/core/IconButton"
9+
import RemoveIcon from "@material-ui/icons/DeleteOutline"
10+
import FileIcon from "@material-ui/icons/FolderOutlined"
11+
12+
const useFileDrop = (
13+
callback: (file: File) => void,
14+
fileTypeRequired?: string,
15+
): {
16+
onDragOver: (e: DragEvent<HTMLDivElement>) => void
17+
onDrop: (e: DragEvent<HTMLDivElement>) => void
18+
} => {
19+
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
20+
e.preventDefault()
21+
}
22+
23+
const onDrop = (e: DragEvent<HTMLDivElement>) => {
24+
e.preventDefault()
25+
const file = e.dataTransfer.files[0]
26+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- file can be undefined
27+
if (!file) {
28+
return
29+
}
30+
if (fileTypeRequired && file.type !== fileTypeRequired) {
31+
return
32+
}
33+
callback(file)
34+
}
35+
36+
return {
37+
onDragOver,
38+
onDrop,
39+
}
40+
}
41+
42+
export interface FileUploadProps {
43+
isUploading: boolean
44+
onUpload: (file: File) => void
45+
onRemove?: () => void
46+
file?: File
47+
removeLabel: string
48+
title: string
49+
description?: ReactNode
50+
extension?: string
51+
fileTypeRequired?: string
52+
}
53+
54+
export const FileUpload: FC<FileUploadProps> = ({
55+
isUploading,
56+
onUpload,
57+
onRemove,
58+
file,
59+
removeLabel,
60+
title,
61+
description,
62+
extension,
63+
fileTypeRequired,
64+
}) => {
65+
const styles = useStyles()
66+
const inputRef = useRef<HTMLInputElement>(null)
67+
const tarDrop = useFileDrop(onUpload, fileTypeRequired)
68+
const clickable = useClickable(() => {
69+
if (inputRef.current) {
70+
inputRef.current.click()
71+
}
72+
})
73+
74+
if (!isUploading && file) {
75+
return (
76+
<Stack
77+
className={styles.file}
78+
direction="row"
79+
justifyContent="space-between"
80+
alignItems="center"
81+
>
82+
<Stack direction="row" alignItems="center">
83+
<FileIcon />
84+
<span>{file.name}</span>
85+
</Stack>
86+
87+
<IconButton title={removeLabel} size="small" onClick={onRemove}>
88+
<RemoveIcon />
89+
</IconButton>
90+
</Stack>
91+
)
92+
}
93+
94+
return (
95+
<>
96+
<div
97+
className={combineClasses({
98+
[styles.root]: true,
99+
[styles.disabled]: isUploading,
100+
})}
101+
{...clickable}
102+
{...tarDrop}
103+
>
104+
<Stack alignItems="center" spacing={1}>
105+
{isUploading ? (
106+
<CircularProgress size={32} />
107+
) : (
108+
<UploadIcon className={styles.icon} />
109+
)}
110+
111+
<Stack alignItems="center" spacing={0.5}>
112+
<span className={styles.title}>{title}</span>
113+
<span className={styles.description}>{description}</span>
114+
</Stack>
115+
</Stack>
116+
</div>
117+
118+
<input
119+
type="file"
120+
ref={inputRef}
121+
className={styles.input}
122+
accept={extension}
123+
onChange={(event) => {
124+
const file = event.currentTarget.files?.[0]
125+
if (file) {
126+
onUpload(file)
127+
}
128+
}}
129+
/>
130+
</>
131+
)
132+
}
133+
134+
const useStyles = makeStyles((theme) => ({
135+
root: {
136+
display: "flex",
137+
alignItems: "center",
138+
justifyContent: "center",
139+
borderRadius: theme.shape.borderRadius,
140+
border: `2px dashed ${theme.palette.divider}`,
141+
padding: theme.spacing(6),
142+
cursor: "pointer",
143+
144+
"&:hover": {
145+
backgroundColor: theme.palette.background.paper,
146+
},
147+
},
148+
149+
disabled: {
150+
pointerEvents: "none",
151+
opacity: 0.75,
152+
},
153+
154+
icon: {
155+
fontSize: theme.spacing(8),
156+
},
157+
158+
title: {
159+
fontSize: theme.spacing(2),
160+
},
161+
162+
description: {
163+
color: theme.palette.text.secondary,
164+
textAlign: "center",
165+
maxWidth: theme.spacing(50),
166+
},
167+
168+
input: {
169+
display: "none",
170+
},
171+
172+
file: {
173+
borderRadius: theme.shape.borderRadius,
174+
border: `1px solid ${theme.palette.divider}`,
175+
padding: theme.spacing(2),
176+
background: theme.palette.background.paper,
177+
},
178+
}))

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy