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

+7
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

+9
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

+2
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

+13
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

+31
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

+7
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} />}
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)