chore!: migrate to docker environment
This commit is contained in:
parent
543cfad176
commit
9497620acd
1
.docker/.buildNodeID
Normal file
1
.docker/.buildNodeID
Normal file
|
|
@ -0,0 +1 @@
|
|||
4a7b79dacb6e1e604537fbbb4cd8ac7f18e0a0f4f5ed963d9914d7753dc170af
|
||||
5
.docker/.token_seed
Normal file
5
.docker/.token_seed
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"registry-1.docker.io": {
|
||||
"Seed": "0Vwpc9pKO8P+IoVikWF8Mw=="
|
||||
}
|
||||
}
|
||||
0
.docker/.token_seed.lock
Normal file
0
.docker/.token_seed.lock
Normal file
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
docs
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/timer"
|
||||
DATABASE_URL="file:./data.db?connection_limit=1"
|
||||
SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS"
|
||||
ALLOW_USER_SIGNUP=1 # remove this line to disable user signup
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -15,3 +15,6 @@ yarn-error.log
|
|||
/build
|
||||
/public/build
|
||||
.env
|
||||
|
||||
*.db
|
||||
*.sqlite
|
||||
|
|
|
|||
67
Dockerfile
Normal file
67
Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# base node image
|
||||
FROM node:16-bullseye-slim as base
|
||||
|
||||
# set for base and all layer that inherit from it
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Install openssl for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl sqlite3
|
||||
|
||||
# Install all node_modules, including dev dependencies
|
||||
FROM base as deps
|
||||
|
||||
WORKDIR /timer
|
||||
|
||||
ADD package.json .npmrc ./
|
||||
RUN npm install --include=dev
|
||||
|
||||
# Setup production node_modules
|
||||
FROM base as production-deps
|
||||
|
||||
WORKDIR /timer
|
||||
|
||||
COPY --from=deps /timer/node_modules /timer/node_modules
|
||||
ADD package.json .npmrc ./
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Build the app
|
||||
FROM base as build
|
||||
|
||||
WORKDIR /timer
|
||||
|
||||
COPY --from=deps /timer/node_modules /timer/node_modules
|
||||
|
||||
ADD prisma .
|
||||
RUN npx prisma generate
|
||||
|
||||
ADD . .
|
||||
RUN npm run build
|
||||
|
||||
# Finally, build the production image with minimal footprint
|
||||
FROM base
|
||||
|
||||
ENV DATABASE_URL=file:/data/sqlite.db
|
||||
ENV PORT="8080"
|
||||
ENV NODE_ENV="production"
|
||||
ENV SESSION_SECRET="${SESSION_SECRET:-za1W297qRgKq6PNtm5EXJlOfIto6WTS}"
|
||||
ENV ALLOW_USER_SIGNUP=0
|
||||
|
||||
# add shortcut for connecting to database CLI
|
||||
RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
|
||||
|
||||
WORKDIR /timer
|
||||
|
||||
COPY --from=production-deps /timer/node_modules /timer/node_modules
|
||||
COPY --from=build /timer/node_modules/.prisma /timer/node_modules/.prisma
|
||||
|
||||
COPY --from=build /timer/build /timer/build
|
||||
COPY --from=build /timer/public /timer/public
|
||||
COPY --from=build /timer/package.json /timer/package.json
|
||||
COPY --from=build /timer/start.sh /timer/start.sh
|
||||
COPY --from=build /timer/prisma /timer/prisma
|
||||
|
||||
RUN chmod +x start.sh
|
||||
|
||||
ENTRYPOINT [ "./start.sh" ]
|
||||
|
||||
EXPOSE 8080
|
||||
|
|
@ -1,7 +1,24 @@
|
|||
export const ALLOW_USER_SIGNUP = Boolean(
|
||||
process.env.ALLOW_USER_SIGNUP || false
|
||||
);
|
||||
import { getSetting } from './models/settings.server';
|
||||
import { countUsers } from './models/user.server';
|
||||
|
||||
export const ALLOW_USER_SIGNUP = process.env.ALLOW_USER_SIGNUP === '1' || false;
|
||||
|
||||
export const isSignupAllowed = async () => {
|
||||
const allowUserSignup = await getSetting({ id: 'ALLOW_USER_SIGNUP' });
|
||||
|
||||
if (allowUserSignup?.value !== undefined && allowUserSignup?.value !== null) {
|
||||
return (
|
||||
allowUserSignup.value === 'true' ||
|
||||
allowUserSignup.value === 'yes' ||
|
||||
allowUserSignup.value === '1' ||
|
||||
allowUserSignup.value === 'on'
|
||||
);
|
||||
}
|
||||
|
||||
let isFirstUser = (await countUsers()) === 0;
|
||||
if (isFirstUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const isSignupAllowed = () => {
|
||||
return !!ALLOW_USER_SIGNUP;
|
||||
};
|
||||
|
|
|
|||
23
app/models/settings.server.ts
Normal file
23
app/models/settings.server.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Settings } from '@prisma/client';
|
||||
|
||||
import { prisma } from '~/db.server';
|
||||
|
||||
export type { Settings } from '@prisma/client';
|
||||
|
||||
export function getSettings() {
|
||||
return prisma.settings.findMany();
|
||||
}
|
||||
|
||||
export function getSetting({ id }: { id: Settings['id'] }) {
|
||||
return prisma.settings.findFirst({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSetting({ id, value }: Settings) {
|
||||
return prisma.settings.upsert({
|
||||
where: { id },
|
||||
update: { value },
|
||||
create: { id, value }
|
||||
});
|
||||
}
|
||||
|
|
@ -13,12 +13,17 @@ export async function getUserByEmail(email: User['email']) {
|
|||
return prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
export async function createUser(email: User['email'], password: string) {
|
||||
export async function createUser(
|
||||
email: User['email'],
|
||||
password: string,
|
||||
admin = false
|
||||
) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
admin,
|
||||
password: {
|
||||
create: {
|
||||
hash: hashedPassword
|
||||
|
|
@ -99,6 +104,10 @@ export async function verifyLogin(
|
|||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
export async function countUsers() {
|
||||
return prisma.user.count();
|
||||
}
|
||||
|
||||
export async function getUsers({
|
||||
search,
|
||||
page,
|
||||
|
|
|
|||
24
app/root.tsx
24
app/root.tsx
|
|
@ -370,6 +370,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
|
|||
}`
|
||||
}}
|
||||
>
|
||||
<Badge variant="light">ADMIN</Badge>
|
||||
<NavLink
|
||||
component={Link}
|
||||
to="/users"
|
||||
|
|
@ -379,10 +380,21 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
|
|||
<Users size={16} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
rightSection={<Badge variant="light">ADMIN</Badge>}
|
||||
variant="light"
|
||||
active={location.pathname.includes('/users')}
|
||||
/>
|
||||
<NavLink
|
||||
component={Link}
|
||||
to="/settings"
|
||||
label="Settings"
|
||||
icon={
|
||||
<ThemeIcon variant="light">
|
||||
<Settings size={16} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
variant="light"
|
||||
active={location.pathname.includes('/settings')}
|
||||
/>
|
||||
</Navbar.Section>
|
||||
)}
|
||||
{user && (
|
||||
|
|
@ -466,7 +478,15 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
|
|||
>
|
||||
<Clock />
|
||||
</ThemeIcon>
|
||||
<span>WorkTimer</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
WorkTimer
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import type { LoaderArgs } from '@remix-run/node';
|
||||
import { json, redirect } from '@remix-run/node';
|
||||
|
||||
import {
|
||||
createStyles,
|
||||
Title,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Button,
|
||||
ThemeIcon,
|
||||
Grid,
|
||||
Col,
|
||||
Image,
|
||||
Container,
|
||||
Group,
|
||||
Box
|
||||
} from '@mantine/core';
|
||||
import { Server, Lock, Users, FileText, GitHub } from 'react-feather';
|
||||
import { getUserId } from '~/session.server';
|
||||
import { Link } from '@remix-run/react';
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await getUserId(request);
|
||||
|
|
@ -9,35 +24,254 @@ export async function loader({ request }: LoaderArgs) {
|
|||
return json({});
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
|
||||
<h1>Welcome to Remix</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://remix.run/tutorials/blog"
|
||||
rel="noreferrer"
|
||||
const features = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Free and open source',
|
||||
description:
|
||||
'All packages are published under GNU Public license v3, you can self host this app and use it for free forever'
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: 'Host anywhere',
|
||||
description:
|
||||
'You can host this app on your own server or using any cloud provider, your choice'
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
title: 'Privacy friendly, you own your data',
|
||||
description:
|
||||
'No analytics or tracking scripts, no ads, no data sharing. You are in control of your data'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Flexible',
|
||||
description:
|
||||
'Use it for yourself as single user or invite your team to collaborate, you can also use it as a public service as admin'
|
||||
}
|
||||
];
|
||||
|
||||
const items = features.map((feature) => (
|
||||
<div key={feature.title}>
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ deg: 133, from: 'blue', to: 'cyan' }}
|
||||
>
|
||||
15m Quickstart Blog Tutorial
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://remix.run/tutorials/jokes"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Deep Dive Jokes App Tutorial
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" href="https://remix.run/docs" rel="noreferrer">
|
||||
Remix Docs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<feature.icon />
|
||||
</ThemeIcon>
|
||||
<Text fz="lg" mt="sm" fw={500}>
|
||||
{feature.title}
|
||||
</Text>
|
||||
<Text c="dimmed" fz="sm">
|
||||
{feature.description}
|
||||
</Text>
|
||||
</div>
|
||||
));
|
||||
|
||||
const rem = (value: number) => `${value / 16}rem`;
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
wrapper: {
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white
|
||||
},
|
||||
|
||||
inner: {
|
||||
position: 'relative',
|
||||
paddingTop: rem(32),
|
||||
paddingBottom: rem(32),
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
paddingBottom: rem(16),
|
||||
paddingTop: rem(16)
|
||||
}
|
||||
},
|
||||
|
||||
title: {
|
||||
fontSize: rem(62),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.1,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
color: theme.colorScheme === 'dark' ? theme.white : theme.black,
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
fontSize: rem(42),
|
||||
lineHeight: 1.2
|
||||
}
|
||||
},
|
||||
|
||||
description: {
|
||||
marginTop: theme.spacing.xl,
|
||||
fontSize: rem(24),
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
fontSize: rem(18)
|
||||
}
|
||||
},
|
||||
|
||||
controls: {
|
||||
marginTop: `calc(${theme.spacing.xl}px * 2)`,
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
marginTop: theme.spacing.xl
|
||||
}
|
||||
},
|
||||
|
||||
control: {
|
||||
height: rem(54),
|
||||
paddingLeft: rem(38),
|
||||
paddingRight: rem(38),
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
height: rem(54),
|
||||
paddingLeft: rem(18),
|
||||
paddingRight: rem(18),
|
||||
flex: 1
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export default function Index() {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container size="md" px="md" className={classes.inner}>
|
||||
<h1 className={classes.title}>
|
||||
A{' '}
|
||||
<Text
|
||||
component="span"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
inherit
|
||||
>
|
||||
self-hosted
|
||||
</Text>{' '}
|
||||
privacy friendly time tracking app
|
||||
</h1>
|
||||
<Text className={classes.description} color="dimmed">
|
||||
Time tracking app built with Remix, supports authentication, projects
|
||||
management, and monthly or custom reports
|
||||
</Text>
|
||||
|
||||
<Group className={classes.controls}>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/login"
|
||||
size="xl"
|
||||
className={classes.control}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
component="a"
|
||||
href="https://github.com/nzambello/work-timer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xl"
|
||||
variant="default"
|
||||
className={classes.control}
|
||||
leftIcon={<GitHub />}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
|
||||
<Container size="md" px="md" mt={120}>
|
||||
<Title mt="xl" mb="md" order={2}>
|
||||
Features
|
||||
</Title>
|
||||
<SimpleGrid
|
||||
mb="xl"
|
||||
cols={2}
|
||||
spacing={30}
|
||||
breakpoints={[{ maxWidth: 'md', cols: 1 }]}
|
||||
>
|
||||
{items}
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="lg" mt={120} align="flex-start">
|
||||
<Col span={12} md={7}>
|
||||
<Title order={3} mb="sm">
|
||||
Light/dark theme
|
||||
</Title>
|
||||
<Group noWrap>
|
||||
<Image
|
||||
maw="50%"
|
||||
mx="auto"
|
||||
radius="md"
|
||||
src="/images/00-time-entries-light.png"
|
||||
alt="Time entries (light theme)"
|
||||
/>
|
||||
<Image
|
||||
maw="50%"
|
||||
mx="auto"
|
||||
radius="md"
|
||||
src="/images/01-time-entries-dark.png"
|
||||
alt="Time entries (dark theme)"
|
||||
/>
|
||||
</Group>
|
||||
</Col>
|
||||
<Col span={12} md={5}>
|
||||
<Title order={3} mb="sm">
|
||||
Time entries management
|
||||
</Title>
|
||||
<Group noWrap>
|
||||
<Image
|
||||
maw="100%"
|
||||
mx="auto"
|
||||
radius="md"
|
||||
src="/images/02-new-time-entry.png"
|
||||
alt="Time entries editing"
|
||||
/>
|
||||
</Group>
|
||||
</Col>
|
||||
<Col span={12} md={5}>
|
||||
<Title order={3} mb="sm">
|
||||
Reports
|
||||
</Title>
|
||||
<Group noWrap>
|
||||
<Image
|
||||
maw="100%"
|
||||
mx="auto"
|
||||
radius="md"
|
||||
src="/images/05-reports.png"
|
||||
alt="Reports"
|
||||
/>
|
||||
</Group>
|
||||
</Col>
|
||||
<Col span={12} md={7}>
|
||||
<Title order={3} mb="sm">
|
||||
Projects
|
||||
</Title>
|
||||
<Group noWrap>
|
||||
<Image
|
||||
maw="50%"
|
||||
mx="auto"
|
||||
radius="md"
|
||||
src="/images/03-projects.png"
|
||||
alt="Projects management"
|
||||
/>
|
||||
<Image
|
||||
maw="50%"
|
||||
mx="auto"
|
||||
radius="md"
|
||||
src="/images/04-new-project.png"
|
||||
alt="Projects management: new project"
|
||||
/>
|
||||
</Group>
|
||||
</Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
Title
|
||||
} from '@mantine/core';
|
||||
import { AtSign, Lock } from 'react-feather';
|
||||
import { verifyLogin } from '~/models/user.server';
|
||||
import { countUsers, verifyLogin } from '~/models/user.server';
|
||||
import { createUserSession, getUserId } from '~/session.server';
|
||||
import { safeRedirect, validateEmail } from '~/utils';
|
||||
import { isSignupAllowed } from '~/config.server';
|
||||
|
|
@ -26,8 +26,11 @@ export async function loader({ request }: LoaderArgs) {
|
|||
const userId = await getUserId(request);
|
||||
if (userId) return redirect('/time-entries');
|
||||
|
||||
const isFirstUser = (await countUsers()) === 0;
|
||||
if (isFirstUser) return redirect('/signup');
|
||||
|
||||
return json({
|
||||
ALLOW_USER_SIGNUP: isSignupAllowed()
|
||||
ALLOW_USER_SIGNUP: await isSignupAllowed()
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
176
app/routes/settings.tsx
Normal file
176
app/routes/settings.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import {
|
||||
Button,
|
||||
Paper,
|
||||
Checkbox,
|
||||
TextInput,
|
||||
Alert,
|
||||
Container
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ActionArgs,
|
||||
json,
|
||||
LoaderArgs,
|
||||
MetaFunction,
|
||||
redirect
|
||||
} from '@remix-run/node';
|
||||
import { Form, useCatch, useLoaderData } from '@remix-run/react';
|
||||
import { requireAdminUserId } from '~/session.server';
|
||||
import { getSettings, updateSetting } from '~/models/settings.server';
|
||||
import { Settings } from '~/models/settings.server';
|
||||
import { isSignupAllowed } from '~/config.server';
|
||||
import { AlertTriangle } from 'react-feather';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: 'Settings | WorkTimer',
|
||||
description:
|
||||
'Manage your WorkTimer instance. You must be logged in to do this.'
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await requireAdminUserId(request);
|
||||
if (!userId) return redirect('/login');
|
||||
|
||||
const settings = await getSettings();
|
||||
|
||||
if (!settings || !settings.find((s) => s.id === 'ALLOW_USER_SIGNUP')) {
|
||||
return json({
|
||||
settings: [
|
||||
...((settings || []).filter((s) => s.id !== 'ALLOW_USER_SIGNUP') || []),
|
||||
{
|
||||
id: 'ALLOW_USER_SIGNUP',
|
||||
value: (await isSignupAllowed()) ? 'true' : 'false'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
settings
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
await requireAdminUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = (formData.get('id') || undefined) as string | undefined;
|
||||
const value = (formData.get('value') || undefined) as
|
||||
| string
|
||||
| boolean
|
||||
| undefined;
|
||||
|
||||
if (!id) {
|
||||
throw new Response('Missing setting id', { status: 422 });
|
||||
}
|
||||
|
||||
let parsedValue;
|
||||
if (value === 'true' || value === 'on' || value === true) {
|
||||
parsedValue = 'true';
|
||||
} else if (value === 'false' || value === 'off' || value === false) {
|
||||
parsedValue = 'false';
|
||||
} else if (typeof value === 'string') {
|
||||
parsedValue = value;
|
||||
} else {
|
||||
parsedValue = 'false';
|
||||
}
|
||||
|
||||
await updateSetting({
|
||||
id,
|
||||
value: parsedValue
|
||||
});
|
||||
|
||||
return redirect('/settings');
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div role="region" id="settings">
|
||||
<Container size="md">
|
||||
{data.settings.map((setting) => (
|
||||
<Form method="patch" key={setting.id}>
|
||||
<input type="hidden" name="id" value={setting.id} />
|
||||
<Paper
|
||||
shadow="sm"
|
||||
p="md"
|
||||
radius="md"
|
||||
mb="sm"
|
||||
display="flex"
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
{['on', 'off', 'true', 'false'].includes(setting.value) ? (
|
||||
<Checkbox
|
||||
label={setting.id}
|
||||
id={setting.id}
|
||||
defaultChecked={
|
||||
setting.value === 'true' || setting.value === 'on'
|
||||
}
|
||||
name="value"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label={setting.id}
|
||||
id={setting.id}
|
||||
name="value"
|
||||
defaultValue={setting.value}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button size="sm" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Paper>
|
||||
</Form>
|
||||
))}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
An unexpected error occurred: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 404) {
|
||||
return (
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
Not found
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
|
||||
import { json, redirect } from '@remix-run/node';
|
||||
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react';
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useSearchParams
|
||||
} from '@remix-run/react';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
|
|
@ -15,7 +21,7 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { AtSign, Check, Lock, X } from 'react-feather';
|
||||
import { createUserSession, getUserId } from '~/session.server';
|
||||
import { createUser, getUserByEmail } from '~/models/user.server';
|
||||
import { countUsers, createUser, getUserByEmail } from '~/models/user.server';
|
||||
import { safeRedirect, validateEmail } from '~/utils';
|
||||
import { isSignupAllowed } from '~/config.server';
|
||||
|
||||
|
|
@ -23,15 +29,21 @@ export async function loader({ request }: LoaderArgs) {
|
|||
const userId = await getUserId(request);
|
||||
if (userId) return redirect('/time-entries');
|
||||
|
||||
if (!isSignupAllowed()) {
|
||||
const isFirstUser = (await countUsers()) === 0;
|
||||
|
||||
if (!(await isSignupAllowed())) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return json({});
|
||||
return json({
|
||||
isFirstUser
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
if (!isSignupAllowed()) {
|
||||
const isFirstUser = (await countUsers()) === 0;
|
||||
|
||||
if (!isSignupAllowed() && !isFirstUser) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
|
|
@ -116,7 +128,7 @@ export async function action({ request }: ActionArgs) {
|
|||
);
|
||||
}
|
||||
|
||||
const user = await createUser(email, password);
|
||||
const user = await createUser(email, password, isFirstUser);
|
||||
|
||||
return createUserSession({
|
||||
request,
|
||||
|
|
@ -175,6 +187,7 @@ function getStrength(password: string) {
|
|||
export default function SignUpPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = searchParams.get('redirectTo') ?? undefined;
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const emailRef = React.useRef<HTMLInputElement>(null);
|
||||
const passwordRef = React.useRef<HTMLInputElement>(null);
|
||||
|
|
@ -293,6 +306,7 @@ export default function SignUpPage() {
|
|||
<Button type="submit">Create account</Button>
|
||||
</Group>
|
||||
|
||||
{!loaderData.isFirstUser && (
|
||||
<Box
|
||||
mt="md"
|
||||
sx={{
|
||||
|
|
@ -304,6 +318,7 @@ export default function SignUpPage() {
|
|||
<strong>Log in</strong>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Form>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,18 @@ export async function requireUserId(
|
|||
return userId;
|
||||
}
|
||||
|
||||
export async function requireAdminUserId(
|
||||
request: Request,
|
||||
redirectTo: string = new URL(request.url).pathname
|
||||
) {
|
||||
const user = await getUser(request);
|
||||
if (!user || !user.admin) {
|
||||
const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
|
||||
throw redirect(`/login?${searchParams}`);
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
export async function requireUser(request: Request) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
|
|
|
|||
24712
package-lock.json
generated
Normal file
24712
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,13 +19,14 @@
|
|||
"@mantine/notifications": "5.10.3",
|
||||
"@mantine/nprogress": "5.10.3",
|
||||
"@mantine/remix": "5.10.3",
|
||||
"@prisma/client": "4.10.1",
|
||||
"@prisma/client": "3.9.1",
|
||||
"@remix-run/node": "^1.15.0",
|
||||
"@remix-run/react": "^1.15.0",
|
||||
"@remix-run/serve": "^1.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"dayjs": "1.11.7",
|
||||
"esbuild": "0.16.3",
|
||||
"isbot": "^3.6.5",
|
||||
"nprogress": "0.2.0",
|
||||
"papaparse": "5.3.2",
|
||||
|
|
@ -39,8 +40,8 @@
|
|||
"@commitlint/cli": "17.4.2",
|
||||
"@commitlint/config-conventional": "17.4.2",
|
||||
"@release-it/conventional-changelog": "5.1.1",
|
||||
"@remix-run/dev": "^1.12.0",
|
||||
"@remix-run/eslint-config": "^1.12.0",
|
||||
"@remix-run/dev": "^1.15.0",
|
||||
"@remix-run/eslint-config": "^1.15.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/nprogress": "0.2.0",
|
||||
|
|
@ -56,7 +57,7 @@
|
|||
"husky": "8.0.3",
|
||||
"is-ci": "3.0.1",
|
||||
"prettier": "2.8.4",
|
||||
"prisma": "^4.9.0",
|
||||
"prisma": "3.9.1",
|
||||
"release-it": "15.6.0",
|
||||
"ts-node": "10.9.1",
|
||||
"typescript": "^4.8.4"
|
||||
|
|
|
|||
BIN
prisma/dev.db-journal
Normal file
BIN
prisma/dev.db-journal
Normal file
Binary file not shown.
|
|
@ -1,60 +0,0 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Password" (
|
||||
"hash" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TimeEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"startTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endTime" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "TimeEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"color" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Password" ADD CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TimeEntry" ADD CONSTRAINT "TimeEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TimeEntry" ADD CONSTRAINT "TimeEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "TimeEntry" ADD COLUMN "duration" DECIMAL(65,30);
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to alter the column `duration` on the `TimeEntry` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `DoublePrecision`.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "TimeEntry" ALTER COLUMN "duration" SET DATA TYPE DOUBLE PRECISION;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "dateFormat" TEXT NOT NULL DEFAULT 'en-US';
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "dateFormat" SET DEFAULT 'en-GB';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "defaultCurrency" TEXT NOT NULL DEFAULT '€',
|
||||
ADD COLUMN "defaultHourlyRate" DOUBLE PRECISION;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `defaultCurrency` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "defaultCurrency",
|
||||
ADD COLUMN "currency" TEXT NOT NULL DEFAULT '€';
|
||||
51
prisma/migrations/20230622073226_init/migration.sql
Normal file
51
prisma/migrations/20230622073226_init/migration.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"admin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"dateFormat" TEXT NOT NULL DEFAULT 'en-GB',
|
||||
"currency" TEXT NOT NULL DEFAULT '€',
|
||||
"defaultHourlyRate" REAL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Password" (
|
||||
"hash" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TimeEntry" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"description" TEXT NOT NULL,
|
||||
"startTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endTime" DATETIME,
|
||||
"duration" REAL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "TimeEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TimeEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"color" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
|
||||
5
prisma/migrations/20230622084526_settings/migration.sql
Normal file
5
prisma/migrations/20230622084526_settings/migration.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "Settings" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" TEXT NOT NULL
|
||||
);
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
provider = "sqlite"
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
datasource db {
|
||||
provider = "postgresql"
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +24,11 @@ model User {
|
|||
projects Project[]
|
||||
}
|
||||
|
||||
model Settings {
|
||||
id String @id
|
||||
value String
|
||||
}
|
||||
|
||||
model Password {
|
||||
hash String
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import bcrypt from 'bcryptjs';
|
|||
const prisma = new PrismaClient();
|
||||
|
||||
async function seed() {
|
||||
const email = 'nicola@rawmaterial.it';
|
||||
const adminEmail = 'admin@rawmaterial.it';
|
||||
const email = 'nicola@nzambello.dev';
|
||||
const adminEmail = 'admin@nzambello.dev';
|
||||
|
||||
// cleanup the existing database
|
||||
await prisma.user.delete({ where: { email } }).catch(() => {
|
||||
|
|
|
|||
BIN
public/images/00-time-entries-light.png
Normal file
BIN
public/images/00-time-entries-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
BIN
public/images/01-time-entries-dark.png
Normal file
BIN
public/images/01-time-entries-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
BIN
public/images/02-new-time-entry.png
Normal file
BIN
public/images/02-new-time-entry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
public/images/03-projects.png
Normal file
BIN
public/images/03-projects.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
BIN
public/images/04-new-project.png
Normal file
BIN
public/images/04-new-project.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
BIN
public/images/05-reports.png
Normal file
BIN
public/images/05-reports.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
19
start.sh
Normal file
19
start.sh
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -ex
|
||||
|
||||
# This file is how Fly starts the server (configured in fly.toml). Before starting
|
||||
# the server though, we need to run any prisma migrations that haven't yet been
|
||||
# run, which is why this file exists in the first place.
|
||||
# Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
|
||||
|
||||
# allocate swap space
|
||||
# fallocate -l 512M /swapfile
|
||||
# chmod 0600 /swapfile
|
||||
# mkswap /swapfile
|
||||
# echo 10 > /proc/sys/vm/swappiness
|
||||
# swapon /swapfile
|
||||
# echo 1 > /proc/sys/vm/overcommit_memory
|
||||
|
||||
npx prisma migrate deploy
|
||||
yarn start
|
||||
Loading…
Reference in a new issue