0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

Merge branch master into merge/sie-v2

This commit is contained in:
wangsijie 2022-11-08 18:15:01 +08:00
commit 5c81d6f147
No known key found for this signature in database
GPG key ID: C72642FE24F7D42B
81 changed files with 1799 additions and 661 deletions

View file

@ -12,13 +12,6 @@
"@logto/integration-tests",
"@logto/ui"
]],
"//": "Ignore other release group members, only keep the major one.",
"ignore": [
"@logto/create",
"@logto/console",
"@logto/integration-tests",
"@logto/ui"
],
"linked": [[
"@logto/phrases",
"@logto/phrases-ui",

View file

@ -0,0 +1,6 @@
---
"@logto/phrases": minor
"@logto/phrases-ui": minor
---
Add German language

18
.changeset/pre.json Normal file
View file

@ -0,0 +1,18 @@
{
"mode": "pre",
"tag": "beta",
"initialVersions": {
"@logto/cli": "1.0.0-beta.12",
"@logto/console": "1.0.0-beta.12",
"@logto/core": "1.0.0-beta.12",
"@logto/create": "1.0.0-beta.12",
"@logto/demo-app": "1.0.0-beta.12",
"@logto/integration-tests": "1.0.0-beta.12",
"@logto/phrases": "1.0.0-beta.12",
"@logto/phrases-ui": "1.0.0-beta.12",
"@logto/schemas": "1.0.0-beta.12",
"@logto/shared": "1.0.0-beta.12",
"@logto/ui": "1.0.0-beta.12"
},
"changesets": []
}

View file

@ -38,9 +38,9 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
node_version: [16, 18]
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
@ -55,6 +55,7 @@ jobs:
- name: Setup Node and pnpm
uses: silverhand-io/actions-node-pnpm-run-steps@v2
with:
node-version: ${{ matrix.node_version }}
run-install: false
# Setup integration test
@ -63,6 +64,9 @@ jobs:
cd tests
pnpm i
pnpm prepack
# Install Chromium
cd packages/integration-tests/node_modules/puppeteer
pnpm postinstall
# Setup environment
- name: Setup Postgres

View file

@ -47,11 +47,17 @@ jobs:
main-test:
runs-on: ubuntu-latest
strategy:
matrix:
node_version: [16, 18]
steps:
- uses: actions/checkout@v3
- name: Setup Node and pnpm
uses: silverhand-io/actions-node-pnpm-run-steps@v2
with:
node-version: ${{ matrix.node_version }}
- name: Prepack
run: pnpm prepack

View file

@ -2,13 +2,14 @@
FROM node:16-alpine as builder
WORKDIR /etc/logto
ENV CI=true
COPY . .
# Install toolchain
RUN npm add --location=global pnpm@^7.2.1
RUN npm add --location=global pnpm@^7.14.0
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#node-gyp-alpine
RUN apk add --no-cache python3 make g++
COPY . .
# Install dependencies and build
RUN pnpm i
RUN pnpm -r build

View file

@ -33,8 +33,8 @@
]
},
"engines": {
"node": ">=14.15.0",
"pnpm": ">=6"
"node": "^16.13.0 || ^18.12.0",
"pnpm": "^7.14.0"
},
"alias": {
"html-parse-stringify": "html-parse-stringify/dist/html-parse-stringify.module.js",

View file

@ -34,7 +34,7 @@
"prepack": "pnpm build"
},
"engines": {
"node": "^16.0.0"
"node": "^16.13.0 || ^18.12.0"
},
"bugs": {
"url": "https://github.com/logto-io/logto/issues"
@ -47,7 +47,7 @@
"decamelize": "^5.0.0",
"dotenv": "^16.0.0",
"fs-extra": "^10.1.0",
"got": "^11.8.2",
"got": "^11.8.5",
"hpagent": "^1.0.0",
"inquirer": "^8.2.2",
"nanoid": "^3.3.4",

View file

@ -27,16 +27,17 @@ export const defaultPath = path.join(os.homedir(), 'logto');
const pgRequired = new semver.SemVer('14.0.0');
export const validateNodeVersion = () => {
const required = new semver.SemVer('16.0.0');
const required = [new semver.SemVer('16.13.0'), new semver.SemVer('18.12.0')];
const requiredVersionString = required.map((version) => '^' + version.version).join(' || ');
const current = new semver.SemVer(execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }));
if (required.compare(current) > 0) {
log.error(`Logto requires NodeJS >=${required.version}, but ${current.version} found.`);
if (required.every((version) => version.major !== current.major)) {
log.error(`Logto requires NodeJS ${requiredVersionString}, but ${current.version} found.`);
}
if (current.major > required.major) {
if (required.some((version) => version.major === current.major && version.compare(current) > 0)) {
log.warn(
`Logto is tested under NodeJS ^${required.version}, but version ${current.version} found.`
`Logto is tested under NodeJS ${requiredVersionString}, but version ${current.version} found.`
);
}
};

View file

@ -22,7 +22,7 @@
"@logto/language-kit": "1.0.0-beta.20",
"@logto/phrases": "workspace:^",
"@logto/phrases-ui": "workspace:^",
"@logto/react": "1.0.0-beta.11",
"@logto/react": "1.0.0-beta.12",
"@logto/schemas": "workspace:^",
"@mdx-js/react": "^1.6.22",
"@parcel/core": "2.7.0",
@ -50,6 +50,7 @@
"csstype": "^3.0.11",
"dayjs": "^1.10.5",
"deep-object-diff": "^1.1.7",
"date-fns": "^2.29.3",
"deepmerge": "^4.2.2",
"dnd-core": "^16.0.0",
"eslint": "^8.21.0",
@ -88,6 +89,17 @@
"typescript": "^4.7.4",
"zod": "^3.19.1"
},
"engines": {
"node": "^16.13.0 || ^18.12.0"
},
"//": "https://github.com/parcel-bundler/parcel/issues/7636",
"targets": {
"default": {
"engines": {
"browsers": "defaults"
}
}
},
"alias": {
"@/*": "./src/$1",
"@mdx/components/*": "./src/mdx-components/$1"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -1,18 +1,18 @@
import type { Nullable } from '@silverhand/essentials';
import dayjs from 'dayjs';
import { isValid } from 'date-fns';
type Props = {
children: Nullable<string | number>;
};
const DateTime = ({ children }: Props) => {
const date = dayjs(children);
const date = children && new Date(children);
if (!children || !date.isValid()) {
if (!date || !isValid(date)) {
return <span>-</span>;
}
return <span>{date.toDate().toLocaleDateString()}</span>;
return <span>{date.toLocaleDateString()}</span>;
};
export default DateTime;

View file

@ -7,7 +7,6 @@ import avatar006 from '@/assets/avatars/avatar-006.png';
import avatar007 from '@/assets/avatars/avatar-007.png';
import avatar008 from '@/assets/avatars/avatar-008.png';
import avatar009 from '@/assets/avatars/avatar-009.png';
import avatar010 from '@/assets/avatars/avatar-010.png';
export const Avatars = [
avatar001,
@ -19,7 +18,6 @@ export const Avatars = [
avatar007,
avatar008,
avatar009,
avatar010,
];
export const generateAvatarPlaceHolderById = (id: string) =>

View file

@ -1,6 +1,5 @@
import type { LogDto, User } from '@logto/schemas';
import classNames from 'classnames';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router-dom';
import useSWR from 'swr';
@ -85,7 +84,7 @@ const AuditLogDetails = () => {
</div>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.time')}</div>
<div>{dayjs(data.createdAt).toDate().toLocaleString()}</div>
<div>{new Date(data.createdAt).toLocaleString()}</div>
</div>
</div>
<div>

View file

@ -1,4 +1,4 @@
import dayjs from 'dayjs';
import { format } from 'date-fns';
import type { ChangeEventHandler } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -36,7 +36,7 @@ const tickFormatter = new Intl.NumberFormat('en-US', {
});
const Dashboard = () => {
const [date, setDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
const [date, setDate] = useState<string>(format(Date.now(), 'yyyy-MM-dd'));
const { data: totalData, error: totalError } = useSWR<TotalUsersResponse, RequestError>(
'/api/dashboard/users/total'
);

View file

@ -4,7 +4,7 @@ import type { ConnectorResponse, ConnectorMetadata, SignInExperience } from '@lo
import { AppearanceMode } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import dayjs from 'dayjs';
import { format } from 'date-fns';
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
@ -195,7 +195,7 @@ const Preview = ({ signInExperience, className }: Props) => {
<div className={classNames(styles.device, styles[mode])}>
{platform !== 'desktopWeb' && (
<div className={styles.topBar}>
<div className={styles.time}>{dayjs().format('HH:mm')}</div>
<div className={styles.time}>{format(Date.now(), 'HH:mm')}</div>
<PhoneInfo />
</div>
)}

View file

@ -30,7 +30,7 @@
"@silverhand/essentials": "^1.3.0",
"chalk": "^4",
"clean-deep": "^3.4.0",
"dayjs": "^1.10.5",
"date-fns": "^2.29.3",
"debug": "^4.3.4",
"decamelize": "^5.0.0",
"deepmerge": "^4.2.2",
@ -38,7 +38,7 @@
"etag": "^1.8.1",
"find-up": "^5.0.0",
"fs-extra": "^10.1.0",
"got": "^11.8.2",
"got": "^11.8.5",
"hash-wasm": "^4.9.0",
"i18next": "^21.8.16",
"iconv-lite": "0.6.3",
@ -55,7 +55,7 @@
"lodash.pick": "^4.4.0",
"module-alias": "^2.2.2",
"nanoid": "^3.1.23",
"oidc-provider": "^7.11.3",
"oidc-provider": "^7.13.0",
"p-retry": "^4.6.1",
"query-string": "^7.0.1",
"roarr": "^7.11.0",
@ -84,7 +84,7 @@
"@types/koa-send": "^4.1.3",
"@types/lodash.pick": "^4.4.6",
"@types/node": "^16.0.0",
"@types/oidc-provider": "^7.11.1",
"@types/oidc-provider": "^7.12.0",
"@types/supertest": "^2.0.11",
"copyfiles": "^2.4.1",
"eslint": "^8.21.0",
@ -100,7 +100,7 @@
"typescript": "^4.7.4"
},
"engines": {
"node": "^16.0.0"
"node": "^16.13.0 || ^18.12.0"
},
"_moduleAliases": {
"@": "./build"

View file

@ -1,2 +1,4 @@
export const isTrue = (value: string) =>
['1', 'true', 'y', 'yes', 'yep', 'yeah'].includes(value.toLowerCase());
export const isTrue = (value?: string) =>
// We need to leverage the native type guard
// eslint-disable-next-line no-implicit-coercion
!!value && ['1', 'true', 'y', 'yes', 'yep', 'yeah'].includes(value.toLowerCase());

View file

@ -3,7 +3,7 @@ import { has } from '@silverhand/essentials';
import type { MiddlewareType } from 'koa';
import koaBody from 'koa-body';
import type { IMiddleware, IRouterParamContext } from 'koa-router';
import type { ZodType } from 'zod';
import type { ZodType, ZodTypeDef } from 'zod';
import envSet from '@/env-set';
import RequestError from '@/errors/RequestError';
@ -48,7 +48,7 @@ export const isGuardMiddleware = <Type extends IMiddleware>(
): function_ is WithGuardConfig<Type> =>
function_.name === 'guardMiddleware' && has(function_, 'config');
const tryParse = <Output, Definition, Input>(
const tryParse = <Output, Definition extends ZodTypeDef, Input>(
type: 'query' | 'body' | 'params',
guard: Optional<ZodType<Output, Definition, Input>>,
data: unknown

View file

@ -35,10 +35,9 @@ jest.mock('@logto/shared', () => ({
const now = Date.now();
jest.mock(
'dayjs',
// eslint-disable-next-line unicorn/consistent-function-scoping
jest.fn(() => () => ({
add: jest.fn((delta: number) => new Date(now + delta * 1000)),
'date-fns',
jest.fn(() => ({
addSeconds: jest.fn((_: Date, seconds: number) => new Date(now + seconds * 1000)),
}))
);

View file

@ -1,7 +1,7 @@
import type { CreateApplication, OidcClientMetadata } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { adminConsoleApplicationId, demoAppApplicationId } from '@logto/schemas/lib/seeds';
import dayjs from 'dayjs';
import { addSeconds } from 'date-fns';
import type { AdapterFactory, AllClientMetadata } from 'oidc-provider';
import snakecaseKeys from 'snakecase-keys';
@ -99,7 +99,7 @@ export default function postgresAdapter(modelName: string): ReturnType<AdapterFa
modelName,
id,
payload,
expiresAt: dayjs().add(expiresIn, 'second').valueOf(),
expiresAt: addSeconds(Date.now(), expiresIn).valueOf(),
}),
find: async (id) => findPayloadById(modelName, id),
findByUserCode: async (userCode) => findPayloadByPayloadField(modelName, 'userCode', userCode),

View file

@ -7,7 +7,7 @@ import { OidcModelInstances } from '@logto/schemas';
import { convertToIdentifiers, convertToTimestamp } from '@logto/shared';
import type { Nullable } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import dayjs from 'dayjs';
import { addSeconds, isBefore } from 'date-fns';
import type { ValueExpression } from 'slonik';
import { sql } from 'slonik';
@ -30,7 +30,7 @@ const isConsumed = (modelName: string, consumedAt: Nullable<number>): boolean =>
return Boolean(consumedAt);
}
return dayjs(consumedAt).add(refreshTokenReuseInterval, 'seconds').isBefore(dayjs());
return isBefore(addSeconds(consumedAt, refreshTokenReuseInterval), Date.now());
};
const withConsumed = <T>(

View file

@ -240,7 +240,7 @@ describe('user query', () => {
const expectSql = sql`
select count(*)
from ${table}
where ${fields.primaryEmail} like $1 or ${fields.primaryPhone} like $2 or ${fields.username} like $3 or ${fields.name} like $4
where ${fields.primaryEmail} ilike $1 or ${fields.primaryPhone} ilike $2 or ${fields.username} ilike $3 or ${fields.name} ilike $4
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
@ -259,7 +259,7 @@ describe('user query', () => {
select count(*)
from ${table}
where not (${fields.roleNames}::jsonb?$1)
and (${fields.primaryEmail} like $2 or ${fields.primaryPhone} like $3 or ${fields.username} like $4 or ${fields.name} like $5)
and (${fields.primaryEmail} ilike $2 or ${fields.primaryPhone} ilike $3 or ${fields.username} ilike $4 or ${fields.name} ilike $5)
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
@ -278,6 +278,24 @@ describe('user query', () => {
await expect(countUsers(search, true)).resolves.toEqual(dbvalue);
});
it('countUsers with isCaseSensitive', async () => {
const search = 'foo';
const expectSql = sql`
select count(*)
from ${table}
where ${fields.primaryEmail} like $1 or ${fields.primaryPhone} like $2 or ${fields.username} like $3 or ${fields.name} like $4
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`]);
return createMockQueryResult([dbvalue]);
});
await expect(countUsers(search, undefined, true)).resolves.toEqual(dbvalue);
});
it('findUsers', async () => {
const search = 'foo';
const limit = 100;
@ -285,9 +303,9 @@ describe('user query', () => {
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.primaryEmail} like $1 or ${fields.primaryPhone} like $2 or ${
where ${fields.primaryEmail} ilike $1 or ${fields.primaryPhone} ilike $2 or ${
fields.username
} like $3 or ${fields.name} like $4
} ilike $3 or ${fields.name} ilike $4
limit $5
offset $6
`;
@ -317,9 +335,9 @@ describe('user query', () => {
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where not (${fields.roleNames}::jsonb?$1)
and (${fields.primaryEmail} like $2 or ${fields.primaryPhone} like $3 or ${
and (${fields.primaryEmail} ilike $2 or ${fields.primaryPhone} ilike $3 or ${
fields.username
} like $4 or ${fields.name} like $5)
} ilike $4 or ${fields.name} ilike $5)
limit $6
offset $7
`;
@ -342,6 +360,37 @@ describe('user query', () => {
await expect(findUsers(limit, offset, search, true)).resolves.toEqual([dbvalue]);
});
it('findUsers with isCaseSensitive', async () => {
const search = 'foo';
const limit = 100;
const offset = 1;
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.primaryEmail} like $1 or ${fields.primaryPhone} like $2 or ${
fields.username
} like $3 or ${fields.name} like $4
limit $5
offset $6
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([
`%${search}%`,
`%${search}%`,
`%${search}%`,
`%${search}%`,
limit,
offset,
]);
return createMockQueryResult([dbvalue]);
});
await expect(findUsers(limit, offset, search, undefined, true)).resolves.toEqual([dbvalue]);
});
it('updateUserById', async () => {
const username = 'Joe';
const id = 'foo';

View file

@ -84,44 +84,64 @@ export const hasUserWithIdentity = async (target: string, userId: string) =>
`
);
const buildUserSearchConditionSql = (search: string) => {
const buildUserSearchConditionSql = (search: string, isCaseSensitive = false) => {
const searchFields = [fields.primaryEmail, fields.primaryPhone, fields.username, fields.name];
const conditions = searchFields.map((filedName) => sql`${filedName} like ${'%' + search + '%'}`);
return sql`${sql.join(conditions, sql` or `)}`;
return sql`${sql.join(
searchFields.map(
(filedName) =>
sql`${filedName} ${isCaseSensitive ? sql`like` : sql`ilike`} ${'%' + search + '%'}`
),
sql` or `
)}`;
};
const buildUserConditions = (search?: string, hideAdminUser?: boolean) => {
const buildUserConditions = (
search?: string,
hideAdminUser?: boolean,
isCaseSensitive?: boolean
) => {
if (hideAdminUser) {
return sql`
where not (${fields.roleNames}::jsonb?${UserRole.Admin})
${conditionalSql(search, (search) => sql`and (${buildUserSearchConditionSql(search)})`)}
${conditionalSql(
search,
(search) => sql`and (${buildUserSearchConditionSql(search, isCaseSensitive)})`
)}
`;
}
return sql`
${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)}
${conditionalSql(
search,
(search) => sql`where ${buildUserSearchConditionSql(search, isCaseSensitive)}`
)}
`;
};
export const countUsers = async (search?: string, hideAdminUser?: boolean) =>
export const countUsers = async (
search?: string,
hideAdminUser?: boolean,
isCaseSensitive?: boolean
) =>
envSet.pool.one<{ count: number }>(sql`
select count(*)
from ${table}
${buildUserConditions(search, hideAdminUser)}
${buildUserConditions(search, hideAdminUser, isCaseSensitive)}
`);
export const findUsers = async (
limit: number,
offset: number,
search?: string,
hideAdminUser?: boolean
hideAdminUser?: boolean,
isCaseSensitive?: boolean
) =>
envSet.pool.any<User>(
sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
${buildUserConditions(search, hideAdminUser)}
${buildUserConditions(search, hideAdminUser, isCaseSensitive)}
limit ${limit}
offset ${offset}
`

View file

@ -4,6 +4,7 @@ import { has } from '@silverhand/essentials';
import pick from 'lodash.pick';
import { literal, object, string } from 'zod';
import { isTrue } from '@/env-set/parameters';
import RequestError from '@/errors/RequestError';
import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
@ -27,18 +28,24 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
'/users',
koaPagination(),
koaGuard({
query: object({ search: string().optional(), hideAdminUser: literal('true').optional() }),
query: object({
search: string().optional(),
// Use `.transform()` once the type issue fixed
hideAdminUser: string().optional(),
isCaseSensitive: string().optional(),
}),
}),
async (ctx, next) => {
const { limit, offset } = ctx.pagination;
const {
query: { search, hideAdminUser: _hideAdminUser },
query: { search, hideAdminUser: _hideAdminUser, isCaseSensitive: _isCaseSensitive },
} = ctx.guard;
const hideAdminUser = _hideAdminUser === 'true';
const hideAdminUser = isTrue(_hideAdminUser);
const isCaseSensitive = isTrue(_isCaseSensitive);
const [{ count }, users] = await Promise.all([
countUsers(search, hideAdminUser),
findUsers(limit, offset, search, hideAdminUser),
countUsers(search, hideAdminUser, isCaseSensitive),
findUsers(limit, offset, search, hideAdminUser, isCaseSensitive),
]);
ctx.pagination.totalCount = count;

View file

@ -40,13 +40,13 @@ jest.mock('@/queries/custom-phrase', () => ({
upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase),
}));
const isValidStructure = jest.fn(
const isStrictlyPartial = jest.fn(
(fullTranslation: Translation, partialTranslation: Partial<Translation>) => true
);
jest.mock('@/utils/translation', () => ({
isValidStructure: (fullTranslation: Translation, partialTranslation: Translation) =>
isValidStructure(fullTranslation, partialTranslation),
isStrictlyPartial: (fullTranslation: Translation, partialTranslation: Translation) =>
isStrictlyPartial(fullTranslation, partialTranslation),
}));
const mockFallbackLanguage = trTrTag;
@ -130,13 +130,13 @@ describe('customPhraseRoutes', () => {
});
});
it('should call isValidStructure', async () => {
it('should call isStrictlyPartial', async () => {
await customPhraseRequest.put(`/custom-phrases/${mockLanguageTag}`).send(translation);
expect(isValidStructure).toBeCalledWith(en.translation, translation);
expect(isStrictlyPartial).toBeCalledWith(en.translation, translation);
});
it('should fail when the input translation structure is invalid', async () => {
isValidStructure.mockReturnValueOnce(false);
isStrictlyPartial.mockReturnValueOnce(false);
const response = await customPhraseRequest
.put(`/custom-phrases/${mockLanguageTag}`)
.send(translation);

View file

@ -15,7 +15,7 @@ import {
} from '@/queries/custom-phrase';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import assertThat from '@/utils/assert-that';
import { isValidStructure } from '@/utils/translation';
import { isStrictlyPartial } from '@/utils/translation';
import type { AuthedRouter } from './types';
@ -70,7 +70,7 @@ export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
const translation = cleanDeepTranslation(body);
assertThat(
isValidStructure(resource.en.translation, translation),
isStrictlyPartial(resource.en.translation, translation),
new RequestError('localization.invalid_translation_structure')
);

View file

@ -1,4 +1,8 @@
import dayjs from 'dayjs';
// The FP version works better for `format()`
/* eslint-disable import/no-duplicates */
import { endOfDay, subDays } from 'date-fns';
import { format } from 'date-fns/fp';
/* eslint-enable import/no-duplicates */
import dashboardRoutes from '@/routes/dashboard';
import { createRequester } from '@/utils/test-utils';
@ -8,6 +12,7 @@ const countUsers = jest.fn(async () => ({ count: totalUserCount }));
const getDailyNewUserCountsByTimeInterval = jest.fn(
async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyNewUserCounts
);
const formatToQueryDate = format('yyyy-MM-dd');
jest.mock('@/queries/user', () => ({
countUsers: async () => countUsers(),
@ -83,8 +88,8 @@ describe('dashboardRoutes', () => {
it('should call getDailyNewUserCountsByTimeInterval with the time interval (14 days ago 23:59:59.999, today 23:59:59.999]', async () => {
await logRequest.get('/dashboard/users/new');
expect(getDailyNewUserCountsByTimeInterval).toHaveBeenCalledWith(
dayjs().endOf('day').subtract(14, 'day').valueOf(),
dayjs().endOf('day').valueOf()
subDays(endOfDay(Date.now()), 14).valueOf(),
endOfDay(Date.now()).valueOf()
);
});
@ -105,10 +110,10 @@ describe('dashboardRoutes', () => {
});
describe('GET /dashboard/users/active', () => {
const mockToday = '2022-05-30';
const mockToday = new Date(2022, 4, 30);
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date(mockToday));
jest.useFakeTimers().setSystemTime(mockToday);
});
it('should fail when the parameter `date` does not match the date regex', async () => {
@ -117,44 +122,44 @@ describe('dashboardRoutes', () => {
});
it('should call getDailyActiveUserCountsByTimeInterval with the time interval (2022-05-31, 2022-06-30] when the parameter `date` is 2022-06-30', async () => {
const targetDate = '2022-06-30';
await logRequest.get(`/dashboard/users/active?date=${targetDate}`);
const targetDate = new Date(2022, 5, 30);
await logRequest.get(`/dashboard/users/active?date=${formatToQueryDate(targetDate)}`);
expect(getDailyActiveUserCountsByTimeInterval).toHaveBeenCalledWith(
dayjs('2022-05-31').endOf('day').valueOf(),
dayjs(targetDate).endOf('day').valueOf()
endOfDay(new Date(2022, 4, 31)).valueOf(),
endOfDay(targetDate).valueOf()
);
});
it('should call getDailyActiveUserCountsByTimeInterval with the time interval (30 days ago, tomorrow] when there is no parameter `date`', async () => {
await logRequest.get('/dashboard/users/active');
expect(getDailyActiveUserCountsByTimeInterval).toHaveBeenCalledWith(
dayjs('2022-04-30').endOf('day').valueOf(),
dayjs(mockToday).endOf('day').valueOf()
endOfDay(new Date(2022, 3, 30)).valueOf(),
endOfDay(mockToday).valueOf()
);
});
it('should call countActiveUsersByTimeInterval with correct parameters when the parameter `date` is 2022-06-30', async () => {
const targetDate = '2022-06-30';
await logRequest.get(`/dashboard/users/active?date=${targetDate}`);
const targetDate = new Date(2022, 5, 30);
await logRequest.get(`/dashboard/users/active?date=${formatToQueryDate(targetDate)}`);
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
1,
dayjs('2022-06-16').endOf('day').valueOf(),
dayjs('2022-06-23').endOf('day').valueOf()
endOfDay(new Date(2022, 5, 16)).valueOf(),
endOfDay(new Date(2022, 5, 23)).valueOf()
);
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
2,
dayjs('2022-06-23').endOf('day').valueOf(),
dayjs(targetDate).endOf('day').valueOf()
endOfDay(new Date(2022, 5, 23)).valueOf(),
endOfDay(targetDate).valueOf()
);
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
3,
dayjs('2022-05-01').endOf('day').valueOf(),
dayjs('2022-05-31').endOf('day').valueOf()
endOfDay(new Date(2022, 4, 1)).valueOf(),
endOfDay(new Date(2022, 4, 31)).valueOf()
);
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
4,
dayjs('2022-05-31').endOf('day').valueOf(),
dayjs(targetDate).endOf('day').valueOf()
endOfDay(new Date(2022, 4, 31)).valueOf(),
endOfDay(targetDate).valueOf()
);
});
@ -162,23 +167,23 @@ describe('dashboardRoutes', () => {
await logRequest.get('/dashboard/users/active');
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
1,
dayjs('2022-05-16').endOf('day').valueOf(),
dayjs('2022-05-23').endOf('day').valueOf()
endOfDay(new Date(2022, 4, 16)).valueOf(),
endOfDay(new Date(2022, 4, 23)).valueOf()
);
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
2,
dayjs('2022-05-23').endOf('day').valueOf(),
dayjs(mockToday).endOf('day').valueOf()
endOfDay(new Date(2022, 4, 23)).valueOf(),
endOfDay(mockToday).valueOf()
);
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
3,
dayjs('2022-03-31').endOf('day').valueOf(),
dayjs('2022-04-30').endOf('day').valueOf()
endOfDay(new Date(2022, 2, 31)).valueOf(),
endOfDay(new Date(2022, 3, 30)).valueOf()
);
expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith(
4,
dayjs('2022-04-30').endOf('day').valueOf(),
dayjs(mockToday).endOf('day').valueOf()
endOfDay(new Date(2022, 3, 30)).valueOf(),
endOfDay(mockToday).valueOf()
);
});

View file

@ -1,6 +1,5 @@
import { dateRegex } from '@logto/core-kit';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { endOfDay, format, subDays } from 'date-fns';
import { object, string } from 'zod';
import koaGuard from '@/middleware/koa-guard';
@ -12,11 +11,11 @@ import { countUsers, getDailyNewUserCountsByTimeInterval } from '@/queries/user'
import type { AuthedRouter } from './types';
const getDateString = (day: Dayjs) => day.format('YYYY-MM-DD');
const getDateString = (date: Date | number) => format(date, 'yyyy-MM-dd');
const indices = (length: number) => [...Array.from({ length }).keys()];
const lastTimestampOfDay = (day: Dayjs) => day.endOf('day').valueOf();
const getEndOfDayTimestamp = (date: Date | number) => endOfDay(date).valueOf();
export default function dashboardRoutes<T extends AuthedRouter>(router: T) {
router.get('/dashboard/users/total', async (ctx, next) => {
@ -27,11 +26,11 @@ export default function dashboardRoutes<T extends AuthedRouter>(router: T) {
});
router.get('/dashboard/users/new', async (ctx, next) => {
const today = dayjs();
const today = Date.now();
const dailyNewUserCounts = await getDailyNewUserCountsByTimeInterval(
// (14 days ago 23:59:59.999, today 23:59:59.999]
lastTimestampOfDay(today.subtract(14, 'day')),
lastTimestampOfDay(today)
getEndOfDayTimestamp(subDays(today, 14)),
getEndOfDayTimestamp(today)
);
const last14DaysNewUserCounts = new Map(
@ -39,15 +38,15 @@ export default function dashboardRoutes<T extends AuthedRouter>(router: T) {
);
const todayNewUserCount = last14DaysNewUserCounts.get(getDateString(today)) ?? 0;
const yesterday = today.subtract(1, 'day');
const yesterday = subDays(today, 1);
const yesterdayNewUserCount = last14DaysNewUserCounts.get(getDateString(yesterday)) ?? 0;
const todayDelta = todayNewUserCount - yesterdayNewUserCount;
const last7DaysNewUserCount = indices(7)
.map((index) => getDateString(today.subtract(index, 'day')))
.map((index) => getDateString(subDays(today, index)))
.reduce((sum, date) => sum + (last14DaysNewUserCounts.get(date) ?? 0), 0);
const newUserCountFrom13DaysAgoTo7DaysAgo = indices(7)
.map((index) => getDateString(today.subtract(7 + index, 'day')))
.map((index) => getDateString(subDays(today, index + 7)))
.reduce((sum, date) => sum + (last14DaysNewUserCounts.get(date) ?? 0), 0);
const last7DaysDelta = last7DaysNewUserCount - newUserCountFrom13DaysAgoTo7DaysAgo;
@ -75,7 +74,7 @@ export default function dashboardRoutes<T extends AuthedRouter>(router: T) {
query: { date },
} = ctx.guard;
const targetDay = date ? dayjs(date) : dayjs(); // Defaults to today
const targetDay = date ? new Date(date) : new Date(); // Defaults to today
const [
// DAU: Daily Active User
last30DauCounts,
@ -88,39 +87,39 @@ export default function dashboardRoutes<T extends AuthedRouter>(router: T) {
] = await Promise.all([
getDailyActiveUserCountsByTimeInterval(
// (30 days ago 23:59:59.999, target day 23:59:59.999]
lastTimestampOfDay(targetDay.subtract(30, 'day')),
lastTimestampOfDay(targetDay)
getEndOfDayTimestamp(subDays(targetDay, 30)),
getEndOfDayTimestamp(targetDay)
),
countActiveUsersByTimeInterval(
// (14 days ago 23:59:59.999, 7 days ago 23:59:59.999]
lastTimestampOfDay(targetDay.subtract(14, 'day')),
lastTimestampOfDay(targetDay.subtract(7, 'day'))
getEndOfDayTimestamp(subDays(targetDay, 14)),
getEndOfDayTimestamp(subDays(targetDay, 7))
),
countActiveUsersByTimeInterval(
// (7 days ago 23:59:59.999, target day 23:59:59.999]
lastTimestampOfDay(targetDay.subtract(7, 'day')),
lastTimestampOfDay(targetDay)
getEndOfDayTimestamp(subDays(targetDay, 7)),
getEndOfDayTimestamp(targetDay)
),
countActiveUsersByTimeInterval(
// (60 days ago 23:59:59.999, 30 days ago 23:59:59.999]
lastTimestampOfDay(targetDay.subtract(60, 'day')),
lastTimestampOfDay(targetDay.subtract(30, 'day'))
getEndOfDayTimestamp(subDays(targetDay, 60)),
getEndOfDayTimestamp(subDays(targetDay, 30))
),
countActiveUsersByTimeInterval(
// (30 days ago 23:59:59.999, target day 23:59:59.999]
lastTimestampOfDay(targetDay.subtract(30, 'day')),
lastTimestampOfDay(targetDay)
getEndOfDayTimestamp(subDays(targetDay, 30)),
getEndOfDayTimestamp(targetDay)
),
]);
const previousDate = getDateString(targetDay.subtract(1, 'day'));
const previousDate = getDateString(subDays(targetDay, 1));
const targetDate = getDateString(targetDay);
const previousDAU = last30DauCounts.find(({ date }) => date === previousDate)?.count ?? 0;
const dau = last30DauCounts.find(({ date }) => date === targetDate)?.count ?? 0;
const dauCurve = indices(30).map((index) => {
const dateString = getDateString(targetDay.subtract(29 - index, 'day'));
const dateString = getDateString(subDays(targetDay, 29 - index));
const count = last30DauCounts.find(({ date }) => date === dateString)?.count ?? 0;
return { date: dateString, count };

View file

@ -1,4 +1,4 @@
import dayjs from 'dayjs';
import { addDays, subSeconds } from 'date-fns';
import { Provider } from 'oidc-provider';
import { mockUser } from '@/__mocks__';
@ -6,6 +6,7 @@ import { createRequester } from '@/utils/test-utils';
import continueRoutes, { continueRoute } from './continue';
const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString();
const getVerificationStorageFromInteraction = jest.fn();
const checkRequiredProfile = jest.fn();
@ -68,7 +69,7 @@ describe('session -> continueRoutes', () => {
result: {
continueSignIn: {
userId: mockUser.id,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -100,7 +101,7 @@ describe('session -> continueRoutes', () => {
result: {
continueSignIn: {
userId: mockUser.id,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -136,7 +137,7 @@ describe('session -> continueRoutes', () => {
result: {
continueSignIn: {
userId: mockUser.id,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -167,7 +168,7 @@ describe('session -> continueRoutes', () => {
result: {
continueSignIn: {
userId: mockUser.id,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -214,7 +215,7 @@ describe('session -> continueRoutes', () => {
result: {
continueSignIn: {
userId: mockUser.id,
expiresAt: dayjs().subtract(1, 'second').toISOString(),
expiresAt: subSeconds(Date.now(), 1).toISOString(),
},
},
});

View file

@ -1,6 +1,6 @@
import type { User } from '@logto/schemas';
import { PasscodeType } from '@logto/schemas';
import dayjs from 'dayjs';
import { addDays, subDays } from 'date-fns';
import { Provider } from 'oidc-provider';
import { mockPasswordEncrypted, mockSignInExperience, mockUserWithPassword } from '@/__mocks__';
@ -16,6 +16,8 @@ const encryptUserPassword = jest.fn(async (password: string) => ({
const findUserById = jest.fn(async (): Promise<User> => mockUserWithPassword);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ userId: 'id' }));
const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
const getYesterdayDate = () => subDays(Date.now(), 1);
const getTomorrowDate = () => addDays(Date.now(), 1);
jest.mock('@/lib/user', () => ({
...jest.requireActual('@/lib/user'),
@ -89,7 +91,7 @@ describe('session -> forgotPasswordRoutes', () => {
result: {
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowDate().toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
@ -110,7 +112,7 @@ describe('session -> forgotPasswordRoutes', () => {
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowDate().toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
@ -126,7 +128,7 @@ describe('session -> forgotPasswordRoutes', () => {
result: {
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowDate().toISOString(),
flow: PasscodeType.SignIn,
},
},
@ -170,7 +172,7 @@ describe('session -> forgotPasswordRoutes', () => {
result: {
verification: {
userId: 'id',
expiresAt: dayjs().subtract(1, 'day').toISOString(),
expiresAt: getYesterdayDate().toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
@ -186,7 +188,7 @@ describe('session -> forgotPasswordRoutes', () => {
result: {
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowDate().toISOString(),
flow: PasscodeType.ForgotPassword,
},
},
@ -203,7 +205,7 @@ describe('session -> forgotPasswordRoutes', () => {
result: {
verification: {
userId: 'id',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowDate().toISOString(),
flow: PasscodeType.ForgotPassword,
},
},

View file

@ -1,7 +1,7 @@
/* eslint-disable max-lines */
import type { User } from '@logto/schemas';
import { PasscodeType, SignInIdentifier, SignUpIdentifier } from '@logto/schemas';
import dayjs from 'dayjs';
import { addDays, addSeconds, subDays } from 'date-fns';
import { Provider } from 'oidc-provider';
import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__';
@ -25,6 +25,7 @@ const findDefaultSignInExperience = jest.fn(async () => ({
verify: true,
},
}));
const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString();
jest.mock('@/lib/user', () => ({
generateUserId: () => 'user1',
@ -203,7 +204,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
flow: PasscodeType.SignIn,
phone: '13000000000',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
},
})
);
@ -227,7 +228,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
flow: PasscodeType.Register,
phone: '13000000000',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
},
})
);
@ -251,7 +252,7 @@ describe('session -> passwordlessRoutes', () => {
expect.objectContaining({
verification: {
userId: mockUser.id,
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
flow: PasscodeType.ForgotPassword,
},
})
@ -300,7 +301,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
flow: PasscodeType.SignIn,
email: 'a@a.com',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
},
})
);
@ -323,7 +324,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
flow: PasscodeType.Register,
email: 'a@a.com',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
},
})
);
@ -347,7 +348,7 @@ describe('session -> passwordlessRoutes', () => {
expect.objectContaining({
verification: {
userId: mockUser.id,
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
flow: PasscodeType.ForgotPassword,
},
})
@ -379,7 +380,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000000',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -403,7 +404,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000000',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -424,7 +425,7 @@ describe('session -> passwordlessRoutes', () => {
result: {
verification: {
phone: '13000000000',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -438,7 +439,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000000',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -466,7 +467,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000000',
flow: PasscodeType.SignIn,
expiresAt: dayjs().subtract(1, 'day').toISOString(),
expiresAt: subDays(Date.now(), 1).toISOString(),
},
},
});
@ -480,7 +481,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'XX@foo',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -494,7 +495,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000001',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -542,7 +543,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'a@a.com',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -566,7 +567,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'a@a.com',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -589,7 +590,7 @@ describe('session -> passwordlessRoutes', () => {
result: {
verification: {
email: 'a@a.com',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -603,7 +604,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'a@a.com',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -616,7 +617,7 @@ describe('session -> passwordlessRoutes', () => {
result: {
verification: {
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -630,7 +631,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'b@a.com',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -677,7 +678,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000001',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -699,7 +700,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000001',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -720,7 +721,7 @@ describe('session -> passwordlessRoutes', () => {
result: {
verification: {
phone: '13000000001',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -734,7 +735,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000001',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -747,7 +748,7 @@ describe('session -> passwordlessRoutes', () => {
result: {
verification: {
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -761,7 +762,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
phone: '13000000000',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -805,7 +806,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'b@a.com',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -827,7 +828,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'b@a.com',
flow: PasscodeType.SignIn,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -848,7 +849,7 @@ describe('session -> passwordlessRoutes', () => {
result: {
verification: {
email: 'b@a.com',
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -862,7 +863,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'b@a.com',
flow: PasscodeType.ForgotPassword,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -875,7 +876,7 @@ describe('session -> passwordlessRoutes', () => {
result: {
verification: {
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});
@ -889,7 +890,7 @@ describe('session -> passwordlessRoutes', () => {
verification: {
email: 'a@a.com',
flow: PasscodeType.Register,
expiresAt: dayjs().add(1, 'day').toISOString(),
expiresAt: getTomorrowIsoString(),
},
},
});

View file

@ -8,7 +8,7 @@ import type {
} from '@logto/schemas';
import { SignUpIdentifier, logTypeGuard } from '@logto/schemas';
import type { Nullable, Truthy } from '@silverhand/essentials';
import dayjs from 'dayjs';
import { addSeconds, isAfter, isValid } from 'date-fns';
import type { Context } from 'koa';
import type { Provider } from 'oidc-provider';
import type { ZodType } from 'zod';
@ -73,8 +73,9 @@ export const getVerificationStorageFromInteraction = async <T = VerificationStor
};
export const checkValidateExpiration = (expiresAt: string) => {
const parsed = new Date(expiresAt);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
isValid(parsed) && isAfter(parsed, Date.now()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
};
@ -88,7 +89,7 @@ export const assignVerificationResult = async (
) => {
const verification: VerificationStorage = {
...verificationData,
expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(),
expiresAt: addSeconds(Date.now(), verificationTimeout).toISOString(),
};
await provider.interactionResult(ctx.req, ctx.res, {
@ -116,7 +117,7 @@ export const assignContinueSignInResult = async (
await provider.interactionResult(ctx.req, ctx.res, {
continueSignIn: {
...payload,
expiresAt: dayjs().add(continueSignInTimeout, 'second').toISOString(),
expiresAt: addSeconds(Date.now(), continueSignInTimeout).toISOString(),
},
});
};
@ -142,8 +143,9 @@ export const getContinueSignInResult = async (
const { expiresAt, ...rest } = signInResult.data.continueSignIn;
const parsed = new Date(expiresAt);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
isValid(parsed) && isAfter(parsed, Date.now()),
new RequestError({ code: 'session.unauthorized', status: 401 })
);

View file

@ -1,7 +1,7 @@
import en from '@logto/phrases-ui/lib/locales/en';
import fr from '@logto/phrases-ui/lib/locales/fr';
import { isValidStructure } from '@/utils/translation';
import { isStrictlyPartial } from '@/utils/translation';
const customizedFrTranslation = {
secondary: {
@ -10,15 +10,15 @@ const customizedFrTranslation = {
},
};
describe('isValidStructure', () => {
describe('isStrictlyPartial', () => {
it('should be true when its structure is valid', () => {
expect(isValidStructure(en.translation, fr.translation)).toBeTruthy();
expect(isValidStructure(en.translation, customizedFrTranslation)).toBeTruthy();
expect(isStrictlyPartial(en.translation, fr.translation)).toBeTruthy();
expect(isStrictlyPartial(en.translation, customizedFrTranslation)).toBeTruthy();
});
it('should be true when the structure is partial and the existing key-value pairs are correct', () => {
expect(
isValidStructure(en.translation, {
isStrictlyPartial(en.translation, {
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
// Missing 'secondary.social_bind_with' key-value pair
@ -29,7 +29,7 @@ describe('isValidStructure', () => {
it('should be false when there is an unexpected key-value pair', () => {
expect(
isValidStructure(en.translation, {
isStrictlyPartial(en.translation, {
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
social_bind_with:

View file

@ -1,38 +1,25 @@
import type { Translation } from '@logto/schemas';
// LOG-4385: Refactor me
// eslint-disable-next-line complexity
export const isValidStructure = (fullTranslation: Translation, partialTranslation: Translation) => {
const fullKeys = new Set(Object.keys(fullTranslation));
const partialKeys = Object.keys(partialTranslation);
if (fullKeys.size === 0 || partialKeys.length === 0) {
return true;
}
if (partialKeys.some((key) => !fullKeys.has(key))) {
return false;
}
for (const [key, value] of Object.entries(fullTranslation)) {
const targetValue = partialTranslation[key];
if (targetValue === undefined) {
continue;
}
if (typeof value === 'string') {
if (typeof targetValue === 'string') {
continue;
}
/**
* @param fullTranslation The translation with full keys
* @param partialTranslation The translation to check
* @returns If the flatten keys of `partialTranslation` is a subset of `fullTranslation`
*/
export const isStrictlyPartial = (
fullTranslation: Translation,
partialTranslation: Translation
): boolean => {
return Object.entries(partialTranslation).every(([key, value]) => {
const fullValue = fullTranslation[key];
if (!fullValue) {
return false;
}
if (typeof targetValue === 'string' || !isValidStructure(value, targetValue)) {
return false;
if (typeof fullValue === 'object' && typeof value === 'object') {
return isStrictlyPartial(fullValue, value);
}
}
return true;
return typeof fullValue === typeof value;
});
};

View file

@ -11,7 +11,7 @@
},
"scripts": {},
"engines": {
"node": "^16.0.0"
"node": "^16.13.0 || ^18.12.0"
},
"dependencies": {
"@logto/cli": "workspace:^"

View file

@ -20,7 +20,7 @@
"@logto/core-kit": "1.0.0-beta.20",
"@logto/language-kit": "1.0.0-beta.20",
"@logto/phrases": "workspace:^",
"@logto/react": "1.0.0-beta.11",
"@logto/react": "1.0.0-beta.12",
"@logto/schemas": "workspace:^",
"@parcel/core": "2.7.0",
"@parcel/transformer-sass": "2.7.0",
@ -44,6 +44,17 @@
"stylelint": "^14.9.1",
"typescript": "^4.7.4"
},
"engines": {
"node": "^16.13.0 || ^18.12.0"
},
"//": "https://github.com/parcel-bundler/parcel/issues/7636",
"targets": {
"default": {
"engines": {
"browsers": "defaults"
}
}
},
"alias": {
"@/*": "./src/$1"
},

View file

@ -1,7 +1,7 @@
import { merge, Config } from '@silverhand/jest-config';
import type { Config } from '@silverhand/jest-config';
import { merge } from '@silverhand/jest-config';
const config: Config.InitialOptions = merge({
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
});

View file

@ -15,7 +15,8 @@
},
"devDependencies": {
"@jest/types": "^29.1.2",
"@logto/node": "1.0.0-beta.11",
"@logto/js": "1.0.0-beta.11",
"@logto/node": "1.0.0-beta.12",
"@logto/schemas": "workspace:^",
"@peculiar/webcrypto": "^1.3.3",
"@silverhand/eslint-config": "1.3.0",
@ -27,18 +28,21 @@
"@types/node": "^16.0.0",
"dotenv": "^16.0.0",
"eslint": "^8.21.0",
"got": "^11.8.2",
"got": "^11.8.5",
"jest": "^29.1.2",
"jest-puppeteer": "^6.1.1",
"node-fetch": "^2.6.7",
"openapi-schema-validator": "^12.0.0",
"openapi-types": "^12.0.0",
"prettier": "^2.7.1",
"puppeteer": "^18.0.0",
"puppeteer": "^19.0.0",
"text-encoder": "^0.0.4",
"ts-node": "^10.9.1",
"typescript": "^4.7.4"
},
"engines": {
"node": "^16.13.0 || ^18.12.0"
},
"eslintConfig": {
"extends": "@silverhand"
},

View file

@ -10,7 +10,7 @@ import { extractCookie } from '@/utils';
import { MemoryStorage } from './storage';
const defaultConfig = {
export const defaultConfig = {
endpoint: logtoUrl,
appId: demoAppApplicationId,
persistAccessToken: false,
@ -18,8 +18,8 @@ const defaultConfig = {
export default class MockClient {
public interactionCookie?: string;
private navigateUrl?: string;
private navigateUrl?: string;
private readonly storage: MemoryStorage;
private readonly logto: LogtoClient;
@ -88,6 +88,10 @@ export default class MockClient {
return this.logto.getAccessToken(resource);
}
public async getRefreshToken() {
return this.logto.getRefreshToken();
}
public async signOut(postSignOutRedirectUri?: string) {
return this.logto.signOut(postSignOutRedirectUri);
}

View file

@ -0,0 +1,4 @@
declare module 'node-fetch' {
const nodeFetch: typeof fetch;
export = nodeFetch;
}

View file

@ -1,8 +1,13 @@
import path from 'path';
import { fetchTokenByRefreshToken } from '@logto/js';
import { managementResource } from '@logto/schemas/lib/seeds';
import { assert } from '@silverhand/essentials';
import fetch from 'node-fetch';
import { signInWithUsernameAndPassword } from '@/api';
import MockClient from '@/client';
import MockClient, { defaultConfig } from '@/client';
import { logtoUrl } from '@/constants';
import { createUserByAdmin } from '@/helpers';
import { generateUsername, generatePassword } from '@/utils';
@ -36,4 +41,41 @@ describe('get access token', () => {
// Request for invalid resource should throw
void expect(client.getAccessToken('api.foo.com')).rejects.toThrow();
});
it('sign-in and get multiple Access Token by the same Refresh Token within refreshTokenReuseInterval', async () => {
const client = new MockClient({ resources: [managementResource.indicator] });
await client.initSession();
assert(client.interactionCookie, new Error('Session not found'));
const { redirectTo } = await signInWithUsernameAndPassword(
username,
password,
client.interactionCookie
);
await client.processSession(redirectTo);
assert(client.isAuthenticated, new Error('Sign in get get access token failed'));
const refreshToken = await client.getRefreshToken();
assert(refreshToken, new Error('No Refresh Token found'));
const getAccessTokenByRefreshToken = async () =>
fetchTokenByRefreshToken(
{
clientId: defaultConfig.appId,
tokenEndpoint: path.join(logtoUrl, '/oidc/token'),
refreshToken,
resource: managementResource.indicator,
},
async <T>(...args: Parameters<typeof fetch>): Promise<T> => {
const response = await fetch(...args);
assert(response.ok, new Error('Request error'));
return response.json();
}
);
// Allow to use the same refresh token to fetch access token within short time period
await Promise.all([getAccessTokenByRefreshToken(), getAccessTokenByRefreshToken()]);
});
});

View file

@ -44,6 +44,9 @@
"prettier": "^2.7.1",
"typescript": "^4.7.4"
},
"engines": {
"node": "^16.13.0 || ^18.12.0"
},
"eslintConfig": {
"extends": "@silverhand"
},

View file

@ -4,6 +4,7 @@ import { languages } from '@logto/language-kit';
import type { NormalizeKeyPaths } from '@silverhand/essentials';
import { z } from 'zod';
import de from './locales/de';
import en from './locales/en';
import fr from './locales/fr';
import ko from './locales/ko';
@ -16,7 +17,7 @@ export type { LocalePhrase } from './types';
export type I18nKey = NormalizeKeyPaths<typeof en.translation>;
export const builtInLanguages = ['en', 'fr', 'pt-PT', 'zh-CN', 'ko', 'tr-TR'] as const;
export const builtInLanguages = ['de', 'en', 'fr', 'ko', 'pt-PT', 'tr-TR', 'zh-CN'] as const;
export const builtInLanguageOptions = builtInLanguages.map((languageTag) => ({
value: languageTag,
@ -30,12 +31,13 @@ export type BuiltInLanguageTag = z.infer<typeof builtInLanguageTagGuard>;
export type Resource = Record<BuiltInLanguageTag, LocalePhrase>;
const resource: Resource = {
de,
en,
fr,
'pt-PT': ptPT,
'zh-CN': zhCN,
ko,
'pt-PT': ptPT,
'tr-TR': trTR,
'zh-CN': zhCN,
};
export const getDefaultLanguageTag = (language: string): LanguageTag =>

View file

@ -0,0 +1,99 @@
import type { LocalePhrase } from '../types';
const translation = {
input: {
username: 'Benutzername',
password: 'Passwort',
email: 'Email',
phone_number: 'Telefonnummer',
confirm_password: 'Passwort bestätigen',
},
secondary: {
sign_in_with: 'Anmelden mit {{methods, list(type: disjunction;)}}',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'Besitzt du schon ein Konto? Melde dich an, um {{methods, list(type: disjunction;)}} mit deiner Identität zu verbinden.',
},
action: {
sign_in: 'Anmelden',
continue: 'Weiter',
create_account: 'Konto erstellen',
create: 'Erstellen',
enter_passcode: 'Bestätigungscode eingeben',
confirm: 'Bestätigen',
cancel: 'Abbrechen',
save_password: 'Speichern',
bind: 'Mit {{address}} verknüpfen',
back: 'Gehe zurück',
nav_back: 'Zurück',
agree: 'Zustimmen',
got_it: 'Alles klar',
sign_in_with: 'Mit {{name}} anmelden',
forgot_password: 'Passwort vergessen?',
switch_to: 'Zu {{method}} wechseln',
sign_in_via_passcode: 'Sign in via verification code', // UNTRANSLATED
sign_in_via_password: 'Sign in via password', // UNTRANSLATED
},
description: {
email: 'Email',
phone_number: 'Telefonnummer',
reminder: 'Erinnerung',
not_found: '404 Nicht gefunden',
agree_with_terms: 'Ich akzeptiere die ',
agree_with_terms_modal: 'Bitte akzeptiere die <link></link>.',
terms_of_use: 'Nutzungsbedingungen',
create_account: 'Konto erstellen',
or: 'oder',
enter_passcode: 'Der Bestätigungscode wurde an deine {{address}} gesendet',
passcode_sent: 'Der Bestätigungscode wurde erneut gesendet',
resend_after_seconds: 'Nach <span>{{seconds}}</span> Sekunden erneut senden',
resend_passcode: 'Bestätigungscode erneut senden',
continue_with: 'Weiter mit',
create_account_id_exists:
'Das Konto mit {{type}} {{value}} existiert bereits, möchtest du dich anmelden?',
sign_in_id_does_not_exists:
'Das Konto mit {{type}} {{value}} existiert nicht, möchtest du ein neues Konto erstellen?',
sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED
forgot_password_id_does_not_exits: 'Das Konto mit {{type}} {{value}} existiert nicht.',
bind_account_title: 'Konto verknüpfen',
social_create_account: 'Kein Konto? Du kannst ein neues Konto erstellen und es verknüpfen.',
social_bind_account:
'Besitzt du schon ein Konto? Melde dich an, um die Identität zu verknüpfen.',
social_bind_with_existing: 'Wir haben ein Konto gefunden, das du verknüpfen kannst.',
reset_password: 'Passwort zurücksetzen',
reset_password_description_email:
'Gib die Email Adresse deines Kontos ein und wir senden dir einen Bestätigungscode um dein Passwort zurückzusetzen.',
reset_password_description_sms:
'Gib die Telefonnummer deines Kontos ein und wir senden dir einen Bestätigungscode um dein Passwort zurückzusetzen.',
new_password: 'Neues Passwort',
password_changed: 'Passwort geändert',
no_account: "Don't have an account?", // UNTRANSLATED
have_account: 'Already have an account?', // UNTRANSLATED
enter_password: 'Enter Password', // UNTRANSLATED
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Benutzername oder Passwort ist falsch',
username_required: 'Benutzername ist erforderlich',
password_required: 'Passwort ist erforderlich',
username_exists: 'Benutzername existiert bereits',
username_should_not_start_with_number: 'Benutzername darf nicht mit einer Zahl beginnen',
username_valid_charset: 'Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten',
invalid_email: 'Die Email ist ungültig',
invalid_phone: 'Die Telefonnummer ist ungültig',
password_min_length: 'Passwort muss mindestens {{min}} Zeichen lang sein',
passwords_do_not_match: 'Passwörter stimmen nicht überein',
invalid_passcode: 'Der Bestätigungscode ist ungültig',
invalid_connector_auth: 'Die Autorisierung ist ungültig',
invalid_connector_request: 'Connector Daten sind ungültig',
unknown: 'Unbekannter Fehler. Versuche es später noch einmal.',
invalid_session: 'Die Sitzung ist ungültig. Bitte melde dich erneut an.',
},
};
const de: LocalePhrase = Object.freeze({
translation,
});
export default de;

View file

@ -25,7 +25,7 @@
"prepack": "pnpm build"
},
"engines": {
"node": "^16.0.0"
"node": "^16.13.0 || ^18.12.0"
},
"bugs": {
"url": "https://github.com/logto-io/logto/issues"

View file

@ -4,6 +4,7 @@ import { languages } from '@logto/language-kit';
import type { NormalizeKeyPaths } from '@silverhand/essentials';
import { z } from 'zod';
import de from './locales/de';
import en from './locales/en';
import fr from './locales/fr';
import ko from './locales/ko';
@ -16,7 +17,7 @@ export type { LocalPhrase } from './types';
export type I18nKey = NormalizeKeyPaths<typeof en.translation>;
export const builtInLanguages = ['en', 'fr', 'pt-PT', 'zh-CN', 'ko', 'tr-TR'] as const;
export const builtInLanguages = ['de', 'en', 'fr', 'ko', 'pt-PT', 'tr-TR', 'zh-CN'] as const;
export const builtInLanguageOptions = builtInLanguages.map((languageTag) => ({
value: languageTag,
@ -42,12 +43,13 @@ export const isBuiltInLanguageTag = (language: string): language is BuiltInLangu
export type Resource = Record<BuiltInLanguageTag, LocalPhrase>;
const resource: Resource = {
de,
en,
fr,
'pt-PT': ptPT,
'zh-CN': zhCN,
ko,
'pt-PT': ptPT,
'tr-TR': trTR,
'zh-CN': zhCN,
};
export default resource;

View file

@ -0,0 +1,152 @@
const errors = {
auth: {
authorization_header_missing: 'Autorisierungs-Header fehlt.',
authorization_token_type_not_supported: 'Autorisierungs-Typ wird nicht unterstützt.',
unauthorized: 'Unautorisiert. Bitte überprüfe deine Zugangsdaten.',
forbidden: 'Verboten. Bitte überprüfe deine Rollen und Berechtigungen.',
expected_role_not_found:
'Erwartete Rolle nicht gefunden. Bitte überprüfe deine Rollen und Berechtigungen.',
jwt_sub_missing: '`sub` fehlt in JWT.',
},
guard: {
invalid_input: 'Die Anfrage {{type}} ist ungültig.',
invalid_pagination: 'Die Paginierung der Anfrage ist ungültig.',
},
oidc: {
aborted: 'Der Endnutzer hat die Interaktion abgebrochen.',
invalid_scope: 'Scope {{scope}} wird nicht unterstützt.',
invalid_scope_plural: 'Scopes {{scopes}} werden nicht unterstützt.',
invalid_token: 'Ungültiger Token übermittelt.',
invalid_client_metadata: 'Ungültige Client Metadaten übermittelt.',
insufficient_scope: 'Access token fehlen angefragte scope {{scopes}}.',
invalid_request: 'Anfrage ist ungültig.',
invalid_grant: 'Grant request ist ungültig.',
invalid_redirect_uri:
'`redirect_uri` stimmt nicht mit den registrierten `redirect_uris` des Clients überein.',
access_denied: 'Zugang verweigert.',
invalid_target: 'Ungültiger resource indicator.',
unsupported_grant_type: 'Nicht unterstützter `grant_type` angefragt.',
unsupported_response_mode: 'Nicht unterstützter `response_mode` angefragt.',
unsupported_response_type: 'Nicht unterstützter `response_type` angefragt.',
provider_error: 'OIDC interner Fehler: {{message}}.',
},
user: {
username_exists_register: 'Der Benutzername wurde registriert.',
email_exists_register: 'Die E-Mail wurde registriert.',
phone_exists_register: 'Die Telefonnummer wurde registriert.',
invalid_email: 'Ungültige E-Mail.',
invalid_phone: 'Ungültige Telefonnummer.',
email_not_exists: 'Die E-Mail wurde noch nicht registriert.',
phone_not_exists: 'Die Telefonnummer wurde noch nicht registriert.',
identity_not_exists: 'Die Identität wurde noch nicht registriert.',
identity_exists: 'Die Identität wurde registriert.',
invalid_role_names: 'Rollennamen ({{roleNames}}) sind ungültig',
cannot_delete_self: 'Du kannst dich nicht selbst löschen.',
sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED
same_password: 'Das neue Passwort muss sich vom alten unterscheiden.',
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
password_exists: 'Your password has been set.', // UNTRANSLATED
require_username: 'You need to set a username before sign in.', // UNTRANSLATED
username_exists: 'Your username has been set.', // UNTRANSLATED
require_email: 'You need to set an email before sign in.', // UNTRANSLATED
email_exists: 'Your email has been set.', // UNTRANSLATED
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
sms_exists: 'Your phone has been set.', // UNTRANSLATED
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.',
pepper_not_found: 'Password pepper not found. Please check your core envs.',
},
session: {
not_found: 'Sitzung nicht gefunden. Bitte melde dich erneut an.',
invalid_credentials: 'Ungültige Zugangsdaten. Überprüfe deine Eingaben.',
invalid_sign_in_method: 'Aktuelle Anmeldemethode ist ungültig.',
invalid_connector_id: 'Connector mit ID {{connectorId}} wurde nicht gefunden.',
insufficient_info: 'Unzureichende Informationen für die Anmeldung.',
connector_id_mismatch: 'Connector ID stimmt nicht mit Sitzung überein.',
connector_session_not_found: 'Connector Sitzung nicht gefunden. Bitte melde dich erneut an.',
verification_session_not_found:
'Die Verifizierung war nicht erfolgreich. Starte die Verifizierung neu und versuche es erneut.',
verification_expired:
'Die Verbindung wurde unterbrochen. Verifiziere erneut, um die Sicherheit deines Kontos zu gewährleisten.',
unauthorized: 'Bitte melde dich erst an.',
unsupported_prompt_name: 'Nicht unterstützter prompt Name.',
forgot_password_not_enabled: 'Forgot password is not enabled.',
},
connector: {
// UNTRANSLATED
general: 'An unexpected error occurred in connector.{{errorDescription}}',
not_found: 'Cannot find any available connector for type: {{type}}.',
not_enabled: 'The connector is not enabled.',
invalid_metadata: "The connector's metadata is invalid.",
invalid_config_guard: "The connector's config guard is invalid.",
unexpected_type: "The connector's type is unexpected.",
invalid_request_parameters: 'The request is with wrong input parameter(s).',
insufficient_request_parameters: 'The request might miss some input parameters.',
invalid_config: "The connector's config is invalid.",
invalid_response: "The connector's response is invalid.",
template_not_found: 'Unable to find correct template in connector config.',
not_implemented: '{{method}}: has not been implemented yet.',
social_invalid_access_token: "The connector's access token is invalid.",
invalid_auth_code: "The connector's auth code is invalid.",
social_invalid_id_token: "The connector's id token is invalid.",
authorization_failed: "The user's authorization process is unsuccessful.",
social_auth_code_invalid: 'Unable to get access token, please check authorization code.',
more_than_one_sms: 'The number of SMS connectors is larger then 1.',
more_than_one_email: 'The number of Email connectors is larger then 1.',
db_connector_type_mismatch: 'There is a connector in the DB that does not match the type.',
},
passcode: {
phone_email_empty: 'Telefonnummer oder E-Mail darf nicht leer sein.',
not_found: 'Passcode nicht gefunden. Bitte sende erst einen Passcode.',
phone_mismatch:
'Telefonnummer stimmt nicht mit Passcode überein. Frage einen neuen Passcode an.',
email_mismatch: 'E-Mail stimmt nicht mit Passcode überein. Frage einen neuen Passcode an.',
code_mismatch: 'Ungültiger Passcode.',
expired: 'Passcode ist abgelaufen. Frage einen neuen Passcode an.',
exceed_max_try: 'Passcode wurde zu oft versucht. Frage einen neuen Passcode an.',
},
sign_in_experiences: {
empty_content_url_of_terms_of_use:
'Leere "Nutzungsbedingungen" URL. Bitte füge die URL hinzu, wenn "Nutzungsbedingungen" aktiviert ist.',
empty_logo: 'Bitte füge eine Logo URL hinzu.',
empty_slogan:
'Leerer Branding-Slogan. Bitte füge einen Branding-Slogan hinzu, wenn ein UI-Stil ausgewählt wird, der den Slogan enthält.',
empty_social_connectors:
'Leere Social Connectors. Bitte füge aktivierte Social Connectoren hinzu, wenn Social Anmeldung aktiviert ist.',
enabled_connector_not_found: 'Aktivierter {{type}} Connector nicht gefunden.',
not_one_and_only_one_primary_sign_in_method:
'Es darf nur eine primäre Anmeldemethode geben. Bitte überprüfe deine Eingabe.',
username_requires_password: 'Must enable set a password for username sign up identifier.', // UNTRANSLATED
passwordless_requires_verify: 'Must enable verify for email/phone sign up identifier.', // UNTRANSLATED
miss_sign_up_identifier_in_sign_in: 'Sign in methods must contain the sign up identifier.', // UNTRANSLATED
password_sign_in_must_be_enabled:
'Password sign in must be enabled when set a password is required in sign up.', // UNTRANSLATED
code_sign_in_must_be_enabled:
'Verification code sign in must be enabled when set a password is not required in sign up.', // UNTRANSLATED
unsupported_default_language: 'Die Sprache - {{language}} wird momentan nicht unterstützt.',
},
localization: {
cannot_delete_default_language:
'{{languageTag}} ist die Standard-Sprache und kann nicht gelöscht werden.',
invalid_translation_structure: 'Ungültige Übersetzungsstruktur. Bitte überprüfe deine Eingabe.',
},
swagger: {
invalid_zod_type: 'Ungültiger Zod Typ. Überprüfe deine route guard Konfiguration.',
not_supported_zod_type_for_params:
'Nicht unterstützter Zod Typ für diese Parameter. Überprüfe deine route guard Konfiguration.',
},
entity: {
create_failed: 'Fehler beim erstellen von {{name}}.',
not_exists: '{{name}} existiert nicht.',
not_exists_with_id: '{{name}} mit ID `{{id}}` existiert nicht.',
not_found: 'Die Ressource wurde nicht gefunden.',
},
log: {
invalid_type: 'Der Log Typ ist ungültig.',
},
};
export default errors;

View file

@ -0,0 +1,10 @@
import type { LocalPhrase } from '../../types';
import errors from './errors';
import translation from './translation';
const de: LocalPhrase = Object.freeze({
translation,
errors,
});
export default de;

View file

@ -0,0 +1,11 @@
const api_resource_details = {
back_to_api_resources: 'Zurück zu API Ressourcen',
token_expiration_time_in_seconds: 'Token Ablaufzeit (in Sekunden)',
token_expiration_time_in_seconds_placeholder: 'Gib die Ablaufzeit des Tokens ein',
delete_description:
'Diese Aktion kann nicht rückgängig gemacht werden. Die API Ressource wird permanent gelöscht. Bitte gib den API Ressourcennamen <span>{{name}}</span> zur Bestätigung ein.',
enter_your_api_resource_name: 'Gib einen API Ressourcennamen ein',
api_resource_deleted: 'Die API Ressource {{name}} wurde erfolgreich gelöscht',
};
export default api_resource_details;

View file

@ -0,0 +1,14 @@
const api_resources = {
title: 'API Ressourcen',
subtitle: 'Lege APIs an, die du in deinen autorisierten Anwendungen verwenden kannst',
create: 'Erstelle API Ressource',
api_name: 'API Name',
api_name_placeholder: 'Gib einen API Namen ein',
api_identifier: 'API Identifikator',
api_identifier_tip:
'Der eindeutige Identifikator der API Ressource muss eine absolute URI ohne Fragmentbezeichner (#) sein. Entspricht dem Ressourcen Parameter in OAuth 2.0.',
api_resource_created: 'Die API Ressource {{name}} wurde erfolgreich angelegt',
api_identifier_placeholder: 'https://dein-api-identifikator/',
};
export default api_resources;

View file

@ -0,0 +1,44 @@
const application_details = {
back_to_applications: 'Zurück zu Anwendungen',
check_guide: 'Zur Anleitung',
advanced_settings: 'Erweiterte Einstellungen',
application_name: 'Anwendungsname',
application_name_placeholder: 'Meine App',
description: 'Beschreibung',
description_placeholder: 'Gib eine Beschreibung ein',
authorization_endpoint: 'Autorisierungs-Endpoint',
authorization_endpoint_tip:
'Der Endpoint, der für die Authentifizierung und Autorisierung via OpenID Connect verwendet wird.',
application_id: 'App ID',
application_secret: 'App Geheimnis',
redirect_uri: 'Umleitungs-URI',
redirect_uris: 'Umleitungs-URIs',
redirect_uri_placeholder: 'https://deine.website.de/app',
redirect_uri_placeholder_native: 'io.logto://callback',
redirect_uri_tip:
'URI zu der der Benutzer nach der Anmeldung (egal ob erfolgreich oder nicht) weitergeleitet wird. See OpenID Connect AuthRequest for more info.',
post_sign_out_redirect_uri: 'Post Sign-out Umleitungs-URI',
post_sign_out_redirect_uris: 'Post Sign-out Umleitungs-URIs',
post_sign_out_redirect_uri_placeholder: 'https://deine.website.de/home',
post_sign_out_redirect_uri_tip:
'URI zu der der Benutzer nach dem Abmelden weitergeleitet wird (optional). Hat bei einigen Anwendungstypen keine Auswirkungen.',
cors_allowed_origins: 'CORS allowed origins',
cors_allowed_origins_placeholder: 'https://your.website.de',
cors_allowed_origins_tip:
'Es sind standardmäßig alle Umleitungs-URI Origins erlaubt. Normalerweise ist dieses Feld nicht erforderlich.',
add_another: 'Weitere hinzufügen',
id_token_expiration: 'ID Token Ablaufzeit',
refresh_token_expiration: 'Refresh Token Ablaufzeit',
token_endpoint: 'Token Endpoint',
user_info_endpoint: 'Benutzerinformations-Endpoint',
enable_admin_access: 'Admin-Zugang aktivieren',
enable_admin_access_label:
'Zugang zur Management API aktivieren oder deaktivieren. Falls aktiviert, können access tokens verwendet werden, um die Management API im Namen der Anwendung aufzurufen.',
delete_description:
'Diese Aktion kann nicht rückgängig gemacht werden. Die Anwendung wird permanent gelöscht. Bitte gib den Anwendungsnamen <span>{{name}}</span> zur Bestätigung ein.',
enter_your_application_name: 'Gib einen Anwendungsnamen ein',
application_deleted: 'Anwendung {{name}} wurde erfolgreich gelöscht',
redirect_uri_required: 'Gebe mindestens eine Umleitungs-URI an',
};
export default application_details;

View file

@ -0,0 +1,50 @@
const applications = {
title: 'Anwendungen',
subtitle:
'Richte eine native, Single Page oder herkömmliche Anwendung ein, die Logto zur Authentifizierung nutzt.',
create: 'Anwendung erstellen',
application_name: 'Anwendungsname',
application_name_placeholder: 'Meine App',
application_description: 'Anwendungsbeschreibung',
application_description_placeholder: 'Gib eine Beschreibung ein',
select_application_type: 'Wähle einen Anwendungstyp',
no_application_type_selected: 'Du hast noch keinen Anwendungstyp ausgewählt',
application_created:
'Die Anwendung {{name}} wurde erfolgreich erstellt! \nKonfiguriere jetzt die Anwendung.',
app_id: 'App ID',
type: {
native: {
title: 'Native App',
subtitle: 'Eine Anwendung, die in einer nativen Umgebung läuft',
description: 'z.B. iOS app, Android app',
},
spa: {
title: 'Single Page App',
subtitle:
'Eine Anwendung, die in einem Webbrowser ausgeführt wird und Daten dynamisch an Ort und Stelle aktualisiert',
description: 'z.B. React DOM app, Vue app',
},
traditional: {
title: 'Herkömmliche Website',
subtitle: 'Eine Anwendung, die Seiten allein durch den Webserver rendert und aktualisiert',
description: 'z.B. Next.js, PHP',
},
machine_to_machine: {
title: 'Machine to Machine',
subtitle: 'Eine Anwendung (normalerweise ein Dienst), die direkt mit Ressourcen kommuniziert',
description: 'z.B. Backend Dienst',
},
},
guide: {
get_sample_file: 'Zum Beispielprojekt',
header_description:
'Folge der Schritt-für-Schritt-Anleitung, um die Anwendung zu integrieren, oder klick auf die rechte Schaltfläche, um unser Beispielprojekt zu erhalten',
title: 'Die Anwendung wurde erfolgreich erstellt',
subtitle:
'Folge nun den folgenden Schritten, um deine App-Einstellungen abzuschließen. Bitte wähle den SDK-Typ aus, um fortzufahren.',
description_by_sdk:
'Diese Schnellstart-Anleitung zeigt, wie man Logto in die {{sdk}} App integriert',
},
};
export default applications;

View file

@ -0,0 +1,22 @@
const connector_details = {
back_to_connectors: 'Zurück zu Connectoren',
check_readme: 'Zur README',
save_error_empty_config: 'Bitte fülle die Konfiguration aus',
send: 'Senden',
send_error_invalid_format: 'Ungültige Eingabe',
edit_config_label: 'Gib deine JSON-Konfiguration ein',
test_email_sender: 'Teste den E-Mail Connector',
test_sms_sender: 'Teste den SMS Connector',
test_email_placeholder: 'Gib eine Test-E-Mail ein',
test_sms_placeholder: 'Gib eine Test-Telefonnummer ein',
test_message_sent: 'Testnachricht wurde gesendet!',
test_sender_description: 'Wenn dein JSON richtig konfiguriert ist, erhältst du eine Nachricht.',
options_change_email: 'E-Mail Connector bearbeiten',
options_change_sms: 'SMS Connector bearbeiten',
connector_deleted: 'Der Connector wurde erfolgreich gelöscht',
type_email: 'E-Mail connector',
type_sms: 'SMS connector',
type_social: 'Social connector',
};
export default connector_details;

View file

@ -0,0 +1,39 @@
const connectors = {
title: 'Connectoren',
subtitle: 'Richte Connectoren ein um passwortlose und Social Anmeldung zu aktivieren',
create: 'Social Connector erstellen',
config_sie_notice: 'Youve set up connectors. Make sure to configure it in <a>{{link}}</a>.', // UNTRANSLATED
config_sie_link_text: 'sign in experience', // UNTRANSLATED
tab_email_sms: 'E-Mail und SMS Connectoren',
tab_social: 'Social Connectoren',
connector_name: 'Connectorname',
connector_type: 'Typ',
connector_status: 'Anmeldeoberfläche',
connector_status_in_use: 'In Benutzung',
connector_status_not_in_use: 'Nicht in Benutzung',
social_connector_eg: 'z.B. Google, Facebook, Github',
save_and_done: 'Speichern und fertigstellen',
type: {
email: 'E-Mail Connector',
sms: 'SMS Connector',
social: 'Social Connector',
},
setup_title: {
email: 'E-Mail Connector einrichten',
sms: 'SMS Connector einrichten',
social: 'Social Connector erstellen',
},
guide: {
subtitle: 'Eine Schritt-für-Schritt-Anleitung zur Konfiguration deines Connectors',
},
platform: {
universal: 'Universal',
web: 'Web',
native: 'Nativ',
},
add_multi_platform: ' unterstützt mehrere Plattformen, wähle eine Plattform aus, um fortzufahren',
drawer_title: 'Connector Anleitung',
drawer_subtitle: 'Folge den Anweisungen, um deinen Connector zu integrieren',
};
export default connectors;

View file

@ -0,0 +1,22 @@
const contact = {
title: 'Kontakt',
description:
'Tritt unserer Community bei, um Feedback zu geben, um Hilfe zu bitten und deine Gedanken mit anderen Entwicklern zu teilen',
discord: {
title: 'Discord channel',
description: 'Tritt unserem öffentlichen Kanal bei, um mit anderen Entwicklern zu chatten',
button: 'Beitreten',
},
github: {
title: 'GitHub',
description: 'Erstelle ein Issue bei GitHub',
button: 'Öffnen',
},
email: {
title: 'E-Mail senden',
description: 'Schick uns eine E-Mail für weitere Informationen und Hilfe',
button: 'Senden',
},
};
export default contact;

View file

@ -0,0 +1,22 @@
const dashboard = {
title: 'Dashboard',
description: 'Verschaffe dir einen Überblick über die Leistung deiner App',
total_users: 'Gesamtzahl der Benutzer',
total_users_tip: 'Gesamtzahl der Benutzer',
new_users_today: 'Neue Benutzer heute',
new_users_today_tip: 'Neue Benutzer, die sich heute in deinen Anwendungen registriert haben',
new_users_7_days: 'Neue Benutzer in den letzten 7 Tagen',
new_users_7_days_tip:
'Neue Benutzer, die sich in den letzten 7 Tagen in deinen Anwendungen registriert haben',
daily_active_users: 'Täglich aktive Benutzer',
daily_active_users_tip:
'Die Anzahl der einzelnen Benutzer, die heute Token in deinen Anwendungen ausgetauscht haben',
weekly_active_users: 'Wöchentlich aktive Benutzer',
weekly_active_users_tip:
'Die Anzahl der einzelnen Benutzer, die in den letzten 7 Tagen Token in deinen Anwendungen ausgetauscht haben',
monthly_active_users: 'Monatlich aktive Benutzer',
monthly_active_users_tip:
'Die Anzahl der einzelnen Benutzer, die in den letzten 30 Tagen Token in deinen Anwendungen ausgetauscht haben',
};
export default dashboard;

View file

@ -0,0 +1,21 @@
const errors = {
something_went_wrong: 'Ups, da ist etwas schief gelaufen.',
page_not_found: 'Seite nicht gefunden',
unknown_server_error: 'Unbekannter Serverfehler',
empty: 'Keine Daten verfügbar',
missing_total_number: 'Total-Number wurde nicht in Response Headern gefunden',
invalid_uri_format: 'Ungültiges URI-Format',
invalid_origin_format: 'Ungültiges URI Origin-Format',
invalid_json_format: 'Ungültiges JSON-Format',
invalid_error_message_format: 'Ungültiges Fehlermeldung-Format.',
required_field_missing: 'Bitte fülle {{field}} aus',
required_field_missing_plural: 'Mindestens ein {{field}} muss ausgefüllt sein',
more_details: 'Mehr Details',
username_pattern_error:
'Der Benutzername sollte nur Buchstaben, Zahlen oder Unterstriche enthalten und nicht mit einer Zahl beginnen.',
password_pattern_error: 'Das Passwort muss aus mindestens 6 Zeichen lang sein',
insecure_contexts: 'Unsichere Kontexte (nicht-HTTPS) werden nicht unterstützt.',
unexpected_error: 'Ein unerwarteter Fehler ist aufgetreten',
};
export default errors;

View file

@ -0,0 +1,43 @@
const general = {
placeholder: 'Platzhalter',
skip: 'Überspringen',
next: 'Weiter',
retry: 'Erneut versuchen',
done: 'Fertig',
search: 'Suche',
search_placeholder: 'Suchen',
clear_result: 'Ergebnisse löschen',
save: 'Speichern',
save_changes: 'Änderungen speichern',
saved: 'Gespeichert!',
loading: 'Lade...',
redirecting: 'Weiterleiten...',
add: 'Hinzufügen',
added: 'Hinzugefügt',
cancel: 'Abbrechen',
confirm: 'Bestätigen',
check_out: 'Ansehen',
create: 'Erstellen',
set_up: 'Einrichten',
customize: 'Anpassen',
enable: 'Aktivieren',
reminder: 'Erinnerung',
delete: 'Löschen',
more_options: 'MEHR OPTIONEN',
close: 'Schließen',
copy: 'Kopieren',
copying: 'Kopiere',
copied: 'Kopiert',
required: 'Erforderlich',
add_another: '+ Weitere hinzufügen',
deletion_confirmation: 'Willst du {{title}} wirklich löschen?',
settings_nav: 'Einstellungen',
unsaved_changes_warning:
'Du hast ungespeicherte Änderungen. Willst du diese Seite wirklich verlassen?',
leave_page: 'Seite verlassen',
stay_on_page: 'Auf Seite bleiben',
type_to_search: 'Tippe um zu suchen',
got_it: 'Got it', // UNTRANSLATED
};
export default general;

View file

@ -0,0 +1,29 @@
const get_started = {
progress: 'Erste Schritte: {{completed}}/{{total}}',
progress_dropdown_title: 'Was du machen kannst...',
title: 'Wie willst du mit Logto loslegen?',
subtitle_part1: 'Ein paar Dinge, die du tun kannst, um schnell von Logto zu profitieren',
subtitle_part2: 'Ich bin fertig mit der Einrichtung.',
hide_this: 'Ausblenden',
confirm_message:
'Bist du sicher, dass du diese Seite ausblenden willst? Diese Aktion kann nicht rückgängig gemacht werden.',
card1_title: 'Zur Demo',
card1_subtitle: 'Probiere die Logto-Anmeldung jetzt aus, um zu sehen, wie sie funktioniert',
card2_title: 'Erste Anwendung erstellen und integrieren',
card2_subtitle:
'Richte eine native, Single Page oder herkömmliche Anwendung ein, die Logto zur Authentifizierung nutzt.',
card3_title: 'Anmeldeoberfläche anpassen',
card3_subtitle:
'Passe die Benutzeroberfläche für die Anmeldung an deine Marke an und zeige eine Vorschau in Echtzeit an',
card4_title: 'SMS- und E-Mail-Verbindung einrichten',
card4_subtitle:
'Probiere die passwortlose Anmeldung mit Telefonnummer oder E-Mail aus, um ein sicheres und reibungsloses Kundenerlebnis zu ermöglichen.',
card5_title: 'Social Connector hinzufügen',
card5_subtitle:
'Lass deine Kunden sich mit einem Klick mit ihren sozialen Identitäten bei deiner App anmelden',
card6_title: 'Weitere Informationen',
card6_subtitle:
'Schau dir unsere schrittweisen, szenariobasierten Dokumentationen ohne langweilige Konzepte an',
};
export default get_started;

View file

@ -0,0 +1,52 @@
import api_resource_details from './api-resource-details';
import api_resources from './api-resources';
import application_details from './application-details';
import applications from './applications';
import connector_details from './connector-details';
import connectors from './connectors';
import contact from './contact';
import dashboard from './dashboard';
import errors from './errors';
import general from './general';
import get_started from './get-started';
import log_details from './log-details';
import logs from './logs';
import session_expired from './session-expired';
import settings from './settings';
import sign_in_exp from './sign-in-exp';
import tab_sections from './tab-sections';
import tabs from './tabs';
import user_details from './user-details';
import users from './users';
import welcome from './welcome';
const admin_console = {
title: 'Admin Konsole',
sign_out: 'Abmelden',
profile: 'Profil',
admin_user: 'Admin',
system_app: 'System',
general,
errors,
tab_sections,
tabs,
applications,
application_details,
api_resources,
api_resource_details,
connectors,
connector_details,
get_started,
users,
user_details,
contact,
sign_in_exp,
settings,
dashboard,
logs,
log_details,
session_expired,
welcome,
};
export default admin_console;

View file

@ -0,0 +1,17 @@
const log_details = {
back_to_logs: 'Zurück zu Audit Logs',
back_to_user: 'Zurück zu {{name}}',
success: 'Erfolgreich',
failed: 'Fehlgeschlagen',
event_type: 'Event Typ',
application: 'Anwendung',
ip_address: 'IP Adresse',
user: 'Benutzer',
log_id: 'Log ID',
time: 'Zeit',
user_agent: 'User agent',
tab_details: 'Details',
raw_data: 'Rohe Daten',
};
export default log_details;

View file

@ -0,0 +1,12 @@
const logs = {
title: 'Audit Logs',
subtitle:
'Anzeige der Log Daten von Authentifizierungsereignissen, die von Admins und Benutzern stammen',
event: 'Event',
user: 'Benutzer',
application: 'Anwendung',
time: 'Zeit',
filter_by: 'Filter nach',
};
export default logs;

View file

@ -0,0 +1,8 @@
const session_expired = {
title: 'Sitzung abgelaufen',
subtitle:
'Deine Sitzung ist möglicherweise abgelaufen und deine Verbindung wurde unterbrochen. Klicke auf die Schaltfläche unten, um dich erneut an der Admin Konsole anzumelden.',
button: 'Erneut anmelden',
};
export default session_expired;

View file

@ -0,0 +1,27 @@
const settings = {
title: 'Einstellungen',
description: 'Verwalte die globalen Einstellungen',
tabs: {
general: 'Allgemein',
},
custom_domain: 'Benutzerdefinierte Domain',
language: 'Sprache',
appearance: 'Darstellung',
appearance_system: 'Synchonisiere mit Systemeinstellungen',
appearance_light: 'Hell',
appearance_dark: 'Dunkel',
saved: 'Gespeichert!',
change_password: 'Passwort ändern',
change_password_description:
'Du kannst das Passwort für dieses Konto ändern. Du verwendest den aktuellen Benutzernamen mit dem neuen Passwort, um dich in der Admin Konsole anzumelden.',
change_modal_title: 'Account Password ändern',
change_modal_description:
'Du verwendest den aktuellen Benutzernamen mit dem neuen Passwort, um dich in der Admin Konsole anzumelden.',
new_password: 'Neues Passwort',
new_password_placeholder: 'Gib ein neues Passwort ein',
confirm_password: 'Passwort bestätigen',
confirm_password_placeholder: 'Bestätige das neue Passwort',
password_changed: 'Passwort geändert!',
};
export default settings;

View file

@ -0,0 +1,187 @@
const sign_in_exp = {
title: 'Anmeldeoberfläche',
description:
'Passe die Benutzeroberfläche für die Anmeldung an deine Marke an und zeige eine Vorschau in Echtzeit an',
tabs: {
branding: 'Branding',
methods: 'Anmeldemethoden',
sign_up_and_sign_in: 'Sign up and Sign in', // UNTRANSLATED
others: 'Andere',
},
welcome: {
title:
'Dies ist das erste Mal, dass du deine Anmeldeoberfläche anpasst. Diese Anleitung hilft dir, alle notwendigen Einstellungen vorzunehmen und schnell loszulegen.',
get_started: 'Erste Schritte',
apply_remind:
'Bitte beachte, dass die Anmeldeoberfläche für alle Anwendungen unter diesem Konto gilt.',
got_it: 'Alles klar',
},
sign_up_and_sign_in: {
identifiers: 'Sign up identifiers', // UNTRANSLATED
identifiers_email: 'Email address', // UNTRANSLATED
identifiers_sms: 'Phone number', // UNTRANSLATED
identifiers_username: 'Username', // UNTRANSLATED
identifiers_email_or_sms: 'Email address or phone number', // UNTRANSLATED
identifiers_none: 'None', // UNTRANSLATED
and: 'and', // UNTRANSLATED
or: 'or', // UNTRANSLATED
sign_up: {
title: 'SIGN UP', // UNTRANSLATED
sign_up_identifier: 'Sign up identifier', // UNTRANSLATED
sign_up_authentication: 'Sign up authentication', // UNTRANSLATED
set_a_password_option: 'Set a password', // UNTRANSLATED
verify_at_sign_up_option: 'Verify at sign up', // UNTRANSLATED
social_only_creation_description: '(This apply to social only account creation)', // UNTRANSLATED
},
sign_in: {
title: 'SIGN IN', // UNTRANSLATED
sign_in_identifier_and_auth: 'Sign in identifier and authentication', // UNTRANSLATED
description:
'Users can use any one of the selected ways to sign in. Drag and drop to define identifier priority regarding the sign in flow. You can also define the password or verification code priority.', // UNTRANSLATED
add_sign_in_method: 'Add Sign-in Method', // UNTRANSLATED
password_auth: 'Password', // UNTRANSLATED
verification_code_auth: 'Verification code', // UNTRANSLATED
auth_swap_tip: 'Swap to change the priority', // UNTRANSLATED
},
social_sign_in: {
title: 'SOCIAL SIGN IN', // UNTRANSLATED
social_sign_in: 'Social sign in', // UNTRANSLATED
description:
'Users may need to enter required identifier when register through social accounts. This was defined by your sign up identifier.', // UNTRANSLATED
add_social_connector: 'Add Social Connector', // UNTRANSLATED
set_up_hint: {
not_in_list: 'Not in the list?', // UNTRANSLATED
set_up_more: 'Set up more', // UNTRANSLATED
go_to: 'social connectors or go to “Connectors” section.', // UNTRANSLATED
},
},
},
color: {
title: 'FARBE',
primary_color: 'Markenfarbe',
dark_primary_color: 'Markenfarbe (Dunkler Modus)',
dark_mode: 'Aktiviere Dunklen Modus',
dark_mode_description:
'Deine App erhält einen automatisch generierten Dunklen Modus, der auf deiner Markenfarbe und dem Logto-Algorithmus basiert. Du kannst diesen nach Belieben anpassen.',
dark_mode_reset_tip: 'Neuberechnung der Farbe des dunklen Modus basierend auf der Markenfarbe.',
reset: 'Neuberechnen',
},
branding: {
title: 'BRANDING',
ui_style: 'Stil',
styles: {
logo_slogan: 'App logo mit Slogan',
logo: 'Nur App logo',
},
logo_image_url: 'App logo URL',
logo_image_url_placeholder: 'https://dein.cdn.domain/logo.png',
dark_logo_image_url: 'App logo URL (Dunkler Modus)',
dark_logo_image_url_placeholder: 'https://dein.cdn.domain/logo-dark.png',
slogan: 'Slogan',
slogan_placeholder: 'Entfessle deine Kreativität',
},
sign_in_methods: {
title: 'ANMELDEMETHODEN',
primary: 'Primäre Anmeldemethode',
enable_secondary: 'Aktiviere sekundäre Anmeldemethoden',
enable_secondary_description:
'Sobald sie aktiviert ist, unterstützt deine App neben der primären Anmeldemethode noch weitere Anmeldemethoden. ',
methods: 'Anmeldemethode',
methods_sms: 'SMS Anmeldung',
methods_email: 'E-Mail Anmeldung',
methods_social: 'Social Anmeldung',
methods_username: 'Benutzername-und-Passwort Anmeldung',
methods_primary_tag: '(Primär)',
define_social_methods: 'Definiere die unterstützten Social Anmeldemethoden',
transfer: {
title: 'Social Connectoren',
footer: {
not_in_list: 'Nicht in der Liste?',
set_up_more: 'Mehr Social Connectoren einrichten',
go_to: 'oder "Connectoren" aufrufen.',
},
},
},
others: {
terms_of_use: {
title: 'NUTZUNGSBEDINGUNGEN',
enable: 'Aktiviere Nutzungsbedingungen',
description: 'Füge die rechtlichen Vereinbarungen für die Nutzung deines Produkts hinzu',
terms_of_use: 'Nutzungsbedingungen',
terms_of_use_placeholder: 'https://beispiel.de/nutzungsbedingungen',
terms_of_use_tip: 'URL zu den Nutzungsbedingungen',
},
languages: {
title: 'SPRACHEN',
enable_auto_detect: 'Aktiviere automatische Spracherkennung',
description:
'Deine Software erkennt die Sprach-Einstellung des Nutzers und schaltet auf die lokale Sprache um. Du kannst neue Sprachen hinzufügen, indem du die Benutzeroberfläche vom Englischen in eine andere Sprache übersetzt.',
manage_language: 'Sprachen verwalten',
default_language: 'Standard-Sprache',
default_language_description_auto:
'Die Standardsprache wird verwendet, wenn die erkannte Benutzersprache nicht in der aktuellen Sprachbibliothek enthalten ist.',
default_language_description_fixed:
'Wenn die automatische Erkennung deaktiviert ist, ist die Standardsprache die einzige Sprache, die deine Software anzeigt. Schalte die automatische Erkennung ein um weitere Sprachen anzuzeigen.',
},
manage_language: {
title: 'Sprachen verwalten',
subtitle:
'Erweitere die Anmeldeoberfläche durch neue Sprachen und Übersetzungen. Deine Übersetzung kann als Standard-Sprache verwendet werden.',
add_language: 'Sprache hinzufügen',
logto_provided: 'Von Logto bereitgestellt',
key: 'Schlüssel',
logto_source_values: 'Logto Übersetzungen',
custom_values: 'Benutzerdefinierte Übersetzungen',
clear_all_tip: 'Alle benutzerdefinierten Übersetzungen löschen',
unsaved_description:
'Wenn du diese Seite verlässt, ohne zu speichern, werden die Änderungen nicht gespeichert.',
deletion_tip: 'Sprache löschen',
deletion_title: 'Willst du diese Sprache wirklich löschen?',
deletion_description:
'Nach dem Löschen können deine Benutzer diese Sprache nicht mehr nutzen.',
default_language_deletion_title: 'Die Standardsprache kann nicht gelöscht werden.',
default_language_deletion_description:
'{{language}} ist als Standardsprache eingestellt und kann nicht gelöscht werden. ',
got_it: 'Alles klar',
},
authentication: {
title: 'AUTHENTIFIZIERUNG',
enable_create_account: 'Aktiviere Registrierung',
enable_create_account_description:
'Aktiviere oder deaktiviere Konto Registrierung. Wenn diese Funktion deaktiviert ist, können deine Kunden keine Konten über die Anmeldeoberfläche erstellen, aber du kannst immer noch Benutzer in der Admin Konsole hinzufügen.',
enable_user_registration: 'Enable user registration', // UNTRANSLATED
enable_user_registration_description:
'Enable or disallow user registration. Once disabled, users can still be added in the admin console but users can no longer establish accounts through the sign-in UI.', // UNTRANSLATED
},
},
setup_warning: {
no_connector: '',
no_connector_sms:
'Du hast noch keinen SMS Connector eingerichtet. Deine Anmeldung wird erst freigeschaltet, wenn du die Einstellungen abgeschlossen hast. ',
no_connector_email:
'Du hast noch keinen E-Mail Connector eingerichtet. Deine Anmeldung wird erst freigeschaltet, wenn du die Einstellungen abgeschlossen hast. ',
no_connector_social:
'Du hast noch keinen Social Connector eingerichtet. Deine Anmeldung wird erst freigeschaltet, wenn du die Einstellungen abgeschlossen hast. ',
no_added_social_connector:
'Du hast jetzt ein paar Social Connectoren eingerichtet. Füge jetzt einige zu deinem Anmeldeerlebnis hinzu.',
},
save_alert: {
description:
'Du änderst die Anmeldemethoden. Das wird sich auf einige deiner Benutzer auswirken. Bist du sicher, dass du das tun willst?',
before: 'Vorher',
after: 'Nachher',
sign_up: 'Sign up', // UNTRANSLATED
sign_in: 'Sign in', // UNTRANSLATED
social: 'Social', // UNTRANSLATED
},
preview: {
title: 'Vorschau',
dark: 'Dunkel',
light: 'Hell',
native: 'Nativ',
desktop_web: 'Desktop Web',
mobile_web: 'Mobil Web',
},
};
export default sign_in_exp;

View file

@ -0,0 +1,8 @@
const tab_sections = {
overview: 'Übersicht',
resource_management: 'Ressourcenverwaltung',
user_management: 'Benutzerverwaltung',
help_and_support: 'Hilfe und Support',
};
export default tab_sections;

View file

@ -0,0 +1,15 @@
const tabs = {
get_started: 'Erste Schritte',
dashboard: 'Dashboard',
applications: 'Anwendungen',
api_resources: 'API Ressourcen',
sign_in_experience: 'Anmeldeoberfläche',
connectors: 'Connectoren',
users: 'Benutzerverwaltung',
audit_logs: 'Audit Logs',
docs: 'Dokumentation',
contact_us: 'Kontakt',
settings: 'Einstellungen',
};
export default tabs;

View file

@ -0,0 +1,41 @@
const user_details = {
back_to_users: 'Zurück zur Benutzerverwaltung',
created_title: 'Der Benutzer wurde erfolgreich erstellt',
created_guide: 'Sende dem Benutzer folgende Anmeldeinformationen',
created_username: 'Benutzername:',
created_password: 'Passwort:',
menu_delete: 'Löschen',
delete_description:
'Diese Aktion kann nicht rückgängig gemacht werden. Der Benutzer wird permanent gelöscht.',
deleted: 'Der Benutzer wurde erfolgreich gelöscht',
reset_password: {
reset_password: 'Passwort zurücksetzen',
title: 'Willst du das Passwort wirklich zurücksetzen?',
content:
'Diese Aktion kann nicht rückgängig gemacht werden. Das Anmeldeinformationen werden zurückgesetzt.',
congratulations: 'Der Benutzer wurde erfolgreich zurückgesetzt',
new_password: 'Neues Passwort:',
},
tab_logs: 'Benutzer-Logs',
field_email: 'Primäre E-Mail',
field_phone: 'Primäre Telefonnummer',
field_username: 'Benutzername',
field_name: 'Name',
field_avatar: 'Profilbild URL',
field_avatar_placeholder: 'https://dein.cdn.domain/profilbild.png',
field_custom_data: 'Benutzerdefinierte Daten',
field_custom_data_tip:
'Zusätzliche Benutzerinformationen, die nicht in den vordefinierten Benutzereigenschaften aufgeführt sind, wie z. B. die vom Benutzer bevorzugte Farbe und Sprache.',
field_connectors: 'Social Connections',
custom_data_invalid: 'Benutzerdefinierte Daten müssen ein gültiges JSON-Objekt sein.',
connectors: {
connectors: 'Connectoren',
user_id: 'Benutzer ID',
remove: 'Löschen',
not_connected: 'Der Nutzer ist nicht mit einem Social Connector verbunden',
deletion_confirmation:
'Du entfernst die bestehende <name/> Identität. Bist du sicher, dass du das tun willst?',
},
};
export default user_details;

View file

@ -0,0 +1,15 @@
const users = {
title: 'Benutzerverwaltung',
subtitle:
'Verwalten von Benutzeridentitäten, einschließlich des Anlegens von Benutzern, Bearbeiten von Benutzerinformationen, Anzeigen von Benutzer-Logs, Zurücksetzen von Passwörtern und Löschen von Benutzern',
create: 'Benutzer hinzufügen',
user_name: 'Benutzer',
application_name: 'Anwendungsname',
latest_sign_in: 'Letzte Anmeldung',
create_form_username: 'Benutzername',
create_form_password: 'Passwort',
create_form_name: 'Name',
unnamed: 'Unbenannt',
};
export default users;

View file

@ -0,0 +1,8 @@
const welcome = {
title: 'Willkommen in der Admin Konsole',
description:
'Die Admin-Konsole ist eine Web-App, mit der du Logto verwalten kannst, ohne programmieren zu müssen. Legen wir zunächst ein Konto an. Mit diesem Konto kannst du Logto selbst oder im Namen deines Unternehmens verwalten.',
create_account: 'Konto erstellen',
};
export default welcome;

View file

@ -0,0 +1,15 @@
const demo_app = {
notification:
'Nutze dein existierendes Admin Konto oder erstelle ein neues Konto um dich in die Demo App einzuloggen.',
title: 'Du hast dich erfolgreich in der Demo App angemeldet!',
subtitle: 'Here is your log in information:',
username: 'Benutzername: ',
user_id: 'Benutzer ID: ',
sign_out: 'Aus der Demo App ausloggen',
continue_explore: 'Oder weiter zum Entdecken',
customize_sign_in_experience: 'Anmeldeoberfläche anpassen',
enable_passwordless: 'Passwordless einschalten',
add_social_connector: 'Social Connector hinzufügen',
};
export default demo_app;

View file

@ -0,0 +1,9 @@
import admin_console from './admin-console';
import demo_app from './demo-app';
const translation = {
admin_console,
demo_app,
};
export default translation;

View file

@ -27,7 +27,7 @@
"test:ci": "jest"
},
"engines": {
"node": "^16.0.0"
"node": "^16.13.0 || ^18.12.0"
},
"devDependencies": {
"@silverhand/eslint-config": "1.3.0",

View file

@ -33,7 +33,7 @@
"typescript": "^4.7.4"
},
"engines": {
"node": "^16.0.0"
"node": "^16.13.0 || ^18.12.0"
},
"eslintConfig": {
"extends": "@silverhand",
@ -45,7 +45,6 @@
"dependencies": {
"@logto/schemas": "workspace:^",
"@silverhand/essentials": "^1.3.0",
"dayjs": "^1.10.5",
"find-up": "^5.0.0",
"nanoid": "^3.3.4",
"slonik": "^30.0.0"

View file

@ -1,4 +1,3 @@
import dayjs from 'dayjs';
import { sql } from 'slonik';
import { SqlToken } from 'slonik/dist/src/tokens.js';
@ -124,7 +123,7 @@ describe('convertToTimestamp()', () => {
});
it('converts to sql per time parameter', () => {
const time = dayjs(123_123_123);
const time = new Date(123_123_123);
expect(convertToTimestamp(time)).toEqual({
sql: 'to_timestamp($1)',

View file

@ -1,7 +1,6 @@
import type { SchemaValuePrimitive, SchemaValue } from '@logto/schemas';
import type { Falsy } from '@silverhand/essentials';
import { notFalsy } from '@silverhand/essentials';
import dayjs from 'dayjs';
import type { SqlSqlToken, SqlToken, QueryResult, IdentifierSqlToken } from 'slonik';
import { sql } from 'slonik';
@ -76,7 +75,8 @@ export const convertToIdentifiers = <T extends Table>({ table, fields }: T, with
};
};
export const convertToTimestamp = (time = dayjs()) => sql`to_timestamp(${time.valueOf() / 1000})`;
export const convertToTimestamp = (time = new Date()) =>
sql`to_timestamp(${time.valueOf() / 1000})`;
export const manyRows = async <T>(query: Promise<QueryResult<T>>): Promise<readonly T[]> => {
const { rows } = await query;

View file

@ -46,7 +46,7 @@
"i18next": "^21.8.16",
"i18next-browser-languagedetector": "^6.1.4",
"jest": "^29.1.2",
"jest-environment-jsdom": "^28.1.3",
"jest-environment-jsdom": "^29.0.0",
"jest-transformer-svg": "^2.0.0",
"js-base64": "^3.7.2",
"ky": "^0.31.0",
@ -69,6 +69,17 @@
"typescript": "^4.7.4",
"use-debounced-loader": "^0.1.1"
},
"engines": {
"node": "^16.13.0 || ^18.12.0"
},
"//": "https://github.com/parcel-bundler/parcel/issues/7636",
"targets": {
"default": {
"engines": {
"browsers": "defaults"
}
}
},
"alias": {
"@/*": "./src/$1"
},

793
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff