Add api token create/revoke

This commit is contained in:
Simon Prévost 2023-01-31 15:02:42 -05:00
parent 84741452e9
commit bb8979d325
72 changed files with 2326 additions and 199 deletions

2
cli/package-lock.json generated
View File

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "accent-cli",
"version": "0.10.2",
"version": "0.12.0",
"license": "MIT",
"dependencies": {
"@oclif/command": "1.8.16",

View File

@ -9,7 +9,7 @@ import ConfigFetcher from './services/config';
import ProjectFetcher from './services/project-fetcher';
// Types
import {Project} from './types/project';
import {Project, ProjectViewer} from './types/project';
const sleep = async (ms: number) =>
new Promise((resolve: (value: unknown) => void) => setTimeout(resolve, ms));
@ -17,6 +17,7 @@ const sleep = async (ms: number) =>
export default abstract class extends Command {
projectConfig: ConfigFetcher = new ConfigFetcher();
project?: Project;
viewer?: ProjectViewer;
async init() {
const config = this.projectConfig.config;
@ -26,10 +27,13 @@ export default abstract class extends Command {
await sleep(1000);
const fetcher = new ProjectFetcher();
this.project = await fetcher.fetch(config);
const response = await fetcher.fetch(config);
this.project = response.project;
this.viewer = response;
if (!this.project) error('Unable to fetch project');
cli.action.stop(chalk.green('✓'));
cli.action.stop(chalk.green(`${this.viewer.user.fullname}`));
if (this.projectConfig.warnings.length) {
console.log('');

View File

@ -43,7 +43,8 @@ export default class ProjectStatsFormatter extends Base {
(memo, revision: Revision) => memo + revision.reviewedCount,
0
);
const percentageReviewed = reviewedCount / translationsCount;
const percentageReviewed =
translationsCount > 0 ? reviewedCount / translationsCount : 0;
const percentageReviewedString = `${percentageReviewed}% reviewed`;
let percentageReviewedFormat = chalk.green(percentageReviewedString);
@ -52,6 +53,8 @@ export default class ProjectStatsFormatter extends Base {
percentageReviewedFormat = chalk.green(percentageReviewedString);
} else if (percentageReviewed > 100 / 2) {
percentageReviewedFormat = chalk.yellow(percentageReviewedString);
} else if (percentageReviewed <= 0) {
percentageReviewedFormat = chalk.dim('No strings');
} else {
percentageReviewedFormat = chalk.red(percentageReviewedString);
}

View File

@ -5,10 +5,10 @@ import fetch from 'node-fetch';
// Types
import {Config} from '../types/config';
import {Project} from '../types/project';
import {ProjectViewer} from '../types/project';
export default class ProjectFetcher {
async fetch(config: Config): Promise<Project> {
async fetch(config: Config): Promise<ProjectViewer> {
const response = await this.graphql(config);
try {
const data = await response.json();
@ -20,7 +20,7 @@ export default class ProjectFetcher {
);
}
return data.data && data.data.viewer.project;
return data.data && data.data.viewer;
} catch (_) {
throw new CLIError(
chalk.red(`Can not fetch the project on ${config.apiUrl}`),
@ -32,6 +32,10 @@ export default class ProjectFetcher {
private async graphql(config: Config) {
const query = `query ProjectDetails($project_id: ID!) {
viewer {
user {
fullname
}
project(id: $project_id) {
id
name

View File

@ -43,6 +43,13 @@ export interface Paginated<T> {
meta: DocumentsMeta;
}
export interface ProjectViewer {
user: {
fullname: string;
};
project: Project;
}
export interface Project {
id: string;
name: string;

View File

@ -16,7 +16,11 @@ defmodule Accent do
{:ok, _} = Logger.add_backend(Sentry.LoggerBackend)
end
Ecto.DevLogger.install(Accent.Repo)
Ecto.DevLogger.install(Accent.Repo,
ignore_event: fn metadata ->
not is_nil(metadata[:options][:telemetry_ui_conf])
end
)
opts = [strategy: :one_for_one, name: Accent.Supervisor]
Supervisor.start_link(children, opts)

View File

@ -0,0 +1,57 @@
defmodule Accent.APITokenManager do
alias Accent.RoleAbilities
alias Accent.{AccessToken, Collaborator, Repo}
alias Ecto.Multi
import Ecto.Changeset
import Ecto.Query
def create(project, user, params) do
params = %{
"custom_permissions" => params[:permissions],
"user" => %{
"fullname" => params[:name],
"picture_url" => params[:picture_url]
}
}
changeset =
%AccessToken{
token: Accent.Utils.SecureRandom.urlsafe_base64(70)
}
|> cast(params, [:custom_permissions])
|> validate_subset(:custom_permissions, Enum.map(RoleAbilities.actions_for(:all), &to_string/1))
|> cast_assoc(:user,
with: fn changeset, params ->
changeset
|> cast(params, [:fullname, :picture_url])
|> put_change(:bot, true)
end
)
Multi.new()
|> Multi.insert(:access_token, changeset)
|> Multi.insert(:collaborator, fn %{access_token: access_token} ->
%Collaborator{user_id: access_token.user_id, role: "bot", assigner_id: user.id, project_id: project.id}
end)
|> Repo.transaction()
end
def revoke(access_token) do
Repo.delete_all(from(AccessToken, where: [id: ^access_token.id]))
:ok
end
def list(project) do
Repo.all(
from(
access_token in AccessToken,
inner_join: user in assoc(access_token, :user),
inner_join: collaboration in assoc(user, :collaborations),
where: collaboration.project_id == ^project.id,
where: user.bot == true,
where: is_nil(access_token.revoked_at)
)
)
end
end

View File

@ -1,4 +1,6 @@
defmodule Accent.RoleAbilities do
require Logger
alias Accent.MachineTranslations
@owner_role "owner"
@ -7,27 +9,17 @@ defmodule Accent.RoleAbilities do
@developer_role "developer"
@reviewer_role "reviewer"
@any_actions ~w(
@read_actions ~w(
lint
format
index_permissions
index_versions
create_version
update_version
export_version
index_translations
index_comments
create_comment
delete_comment
update_comment
show_comment
correct_all_revision
uncorrect_all_revision
show_project
index_collaborators
correct_translation
uncorrect_translation
update_translation
show_translation
index_project_activities
index_related_translations
@ -38,17 +30,30 @@ defmodule Accent.RoleAbilities do
show_activity
index_translation_activities
index_translation_comments_subscriptions
)a
@write_actions ~w(
create_comment
delete_comment
update_comment
correct_all_revision
uncorrect_all_revision
correct_translation
uncorrect_translation
update_translation
create_translation_comments_subscription
delete_translation_comments_subscription
)a
@any_actions @read_actions ++ @write_actions
@bot_actions ~w(
peek_sync
peek_merge
merge
sync
hook_update
)a ++ @any_actions
)a ++ @read_actions
@developer_actions ~w(
peek_sync
@ -57,11 +62,15 @@ defmodule Accent.RoleAbilities do
sync
delete_document
update_document
show_project_access_token
list_project_api_tokens
create_project_api_token
revoke_project_api_token
index_project_integrations
create_project_integration
update_project_integration
delete_project_integration
create_version
update_version
)a ++ @any_actions
@admin_actions ~w(
@ -78,8 +87,10 @@ defmodule Accent.RoleAbilities do
delete_project
)a ++ @developer_actions
@configurable_actions ~w(machine_translations_translate_document achine_translations_translate_file machine_translations_translate_text)a
@configurable_actions ~w(machine_translations_translate_document machine_translations_translate_file machine_translations_translate_text)a
def actions_for(:all), do: add_configurable_actions(@admin_actions, @owner_role)
def actions_for({:custom, permissions}), do: permissions
def actions_for(@owner_role), do: add_configurable_actions(@admin_actions, @owner_role)
def actions_for(@admin_role), do: add_configurable_actions(@admin_actions, @admin_role)
def actions_for(@bot_role), do: add_configurable_actions(@bot_actions, @bot_role)
@ -96,17 +107,27 @@ defmodule Accent.RoleAbilities do
MachineTranslations.translate_list_enabled?()
end
def can?(_role, :machine_translations_translate_document), do: false
def can?(role, :machine_translations_translate_file) when role in [@owner_role, @admin_role, @developer_role] do
MachineTranslations.translate_list_enabled?()
end
def can?(_role, :machine_translations_translate_file), do: false
def can?(role, :machine_translations_translate_text) when role in [@owner_role, @admin_role, @developer_role] do
MachineTranslations.translate_text_enabled?()
end
def can?(_role, :machine_translations_translate_text), do: false
# Define abilities function at compile time to remove list lookup at runtime
def can?(@owner_role, _action), do: true
def can?({:custom, permissions}, action) do
to_string(action) in permissions
end
for action <- @admin_actions do
def can?(@admin_role, unquote(action)), do: true
end
@ -124,5 +145,10 @@ defmodule Accent.RoleAbilities do
end
# Fallback if no permission has been found for the user on the project
def can?(_role, _action), do: false
def can?(nil, _action), do: false
def can?(role, action) do
Logger.warn("Unauthorized action: #{action} for #{role}")
false
end
end

View File

@ -21,17 +21,21 @@ defmodule Accent.UserAuthFetcher do
from(
user in User,
inner_join: access_token in assoc(user, :access_tokens),
left_join: collaboration in assoc(user, :bot_collaborations),
where: access_token.token == ^token,
where: is_nil(access_token.revoked_at)
where: is_nil(access_token.revoked_at),
select: {user, access_token.custom_permissions, collaboration}
)
|> Repo.one()
end
defp fetch_user(_any), do: nil
defp map_permissions(nil), do: nil
defp map_permissions({user, [_ | _] = permissions, %{project_id: id}}) do
%{user | permissions: %{id => {:custom, permissions}}}
end
defp map_permissions(user) do
defp map_permissions({user, _, _}) do
permissions =
from(
collaborator in Collaborator,
@ -43,4 +47,6 @@ defmodule Accent.UserAuthFetcher do
%{user | permissions: permissions}
end
defp map_permissions(nil), do: nil
end

View File

@ -22,12 +22,15 @@ defmodule Accent.UserRemote.Authenticator do
%User{
provider: to_string(provider),
fullname: info.name,
picture_url: info.image,
picture_url: normalize_picture_url(info.image, provider),
email: normalize_email(info.email),
uid: normalize_email(info.email)
}
end
defp normalize_picture_url("https://lh3.googleusercontent.com/a/default-user" <> _, :google), do: nil
defp normalize_picture_url(url, _provider), do: url
defp normalize_email(email) do
String.downcase(email)
end

View File

@ -49,7 +49,7 @@ defmodule Accent.Endpoint do
end
# sobelow_skip ["XSS.SendResp"]
defp ping(%{request_path: "/ping"} = conn, _opts) do
defp ping(conn = %{request_path: "/ping"}, _opts) do
alias Plug.Conn
version = Application.get_env(:accent, :version)
@ -61,7 +61,7 @@ defmodule Accent.Endpoint do
defp ping(conn, _opts), do: conn
defp canonical_host(%{request_path: "/health"} = conn, _opts), do: conn
defp canonical_host(conn = %{request_path: "/health"}, _opts), do: conn
defp canonical_host(conn, _opts) do
opts = PlugCanonicalHost.init(canonical_host: Application.get_env(:accent, :canonical_host))

View File

@ -3,7 +3,7 @@ defmodule Accent.ProjectDeleter do
def delete(project: project) do
project
|> Ecto.assoc(:collaborators)
|> Ecto.assoc(:all_collaborators)
|> Repo.delete_all()
{:ok, project}

View File

@ -5,6 +5,7 @@ defmodule Accent.AccessToken do
field(:token, :string)
field(:global, :boolean)
field(:revoked_at, :naive_datetime)
field(:custom_permissions, {:array, :string})
belongs_to(:user, Accent.User)

View File

@ -17,7 +17,8 @@ defmodule Accent.Project do
has_many(:target_revisions, Accent.Revision, where: [master: false])
has_many(:versions, Accent.Version)
has_many(:operations, Accent.Operation)
has_many(:collaborators, Accent.Collaborator)
has_many(:collaborators, Accent.Collaborator, where: [role: {:in, ["reviewer", "admin", "developer", "owner"]}])
has_many(:all_collaborators, Accent.Collaborator)
belongs_to(:language, Accent.Language)
timestamps()

View File

@ -12,6 +12,7 @@ defmodule Accent.User do
has_many(:private_access_tokens, Accent.AccessToken, where: [global: false])
has_many(:auth_providers, Accent.AuthProvider)
has_many(:collaborations, Accent.Collaborator)
has_many(:bot_collaborations, Accent.Collaborator, where: [role: "bot"])
has_many(:collaboration_assigns, Accent.Collaborator, foreign_key: :assigner_id)
field(:permissions, :map, virtual: true)

View File

@ -83,14 +83,12 @@ defmodule Accent.TelemetryUI do
description: "Database query total time",
keep: ecto_keep,
unit: {:native, :millisecond},
reporter_options: [report_as: "ecto"],
ui_options: [class: "col-span-3", unit: " ms"]
),
average_over_time("accent.repo.query.total_time",
description: "Database query total time over time",
keep: ecto_keep,
unit: {:native, :millisecond},
reporter_options: [report_as: "ecto"],
ui_options: [class: "col-span-5", unit: " ms"]
),
average("accent.repo.query.total_time",
@ -98,7 +96,6 @@ defmodule Accent.TelemetryUI do
keep: ecto_keep,
tags: [:source],
unit: {:native, :millisecond},
reporter_options: [report_as: "ecto"],
ui_options: [class: "col-span-full", unit: " ms"]
)
]
@ -119,28 +116,24 @@ defmodule Accent.TelemetryUI do
average("absinthe.execute.operation.stop.duration",
description: "Absinthe operation duration",
unit: {:native, :millisecond},
reporter_options: [report_as: "absinthe"],
ui_options: [class: "col-span-3", unit: " ms"]
),
average_over_time("absinthe.execute.operation.stop.duration",
description: "Absinthe operation duration over time",
unit: {:native, :millisecond},
reporter_options: [report_as: "absinthe"],
ui_options: [class: "col-span-5", unit: " ms"]
),
counter("absinthe.execute.operation.stop.duration",
description: "Count Absinthe executions per operation",
tags: [:operation_name],
tag_values: absinthe_tag_values,
unit: {:native, :millisecond},
reporter_options: [report_as: "absinthe"]
unit: {:native, :millisecond}
),
average_over_time("absinthe.execute.operation.stop.duration",
description: "Absinthe duration per operation",
tags: [:operation_name],
tag_values: absinthe_tag_values,
unit: {:native, :millisecond},
reporter_options: [report_as: "absinthe"]
unit: {:native, :millisecond}
)
]
end
@ -165,65 +158,69 @@ defmodule Accent.TelemetryUI do
end
[
counter("phoenix.router_dispatch.stop.duration",
counter("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "Number of GraphQL requests",
keep: graphql_keep,
unit: {:native, :millisecond},
reporter_options: [report_as: "graphql"],
ui_options: [class: "col-span-3", unit: " requests"]
),
count_over_time("phoenix.router_dispatch.stop.duration",
count_over_time("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "Number of GraphQL requests over time",
keep: graphql_keep,
unit: {:native, :millisecond},
reporter_options: [report_as: "graphql"],
ui_options: [class: "col-span-5", unit: " requests"]
),
average("phoenix.router_dispatch.stop.duration",
average("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "GraphQL requests duration",
keep: graphql_keep,
unit: {:native, :millisecond},
reporter_options: [report_as: "graphql"],
ui_options: [class: "col-span-3", unit: " ms"]
),
average_over_time("phoenix.router_dispatch.stop.duration",
average_over_time("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "GraphQL requests duration over time",
keep: graphql_keep,
unit: {:native, :millisecond},
reporter_options: [report_as: "graphql"],
ui_options: [class: "col-span-5", unit: " ms"]
),
count_over_time("phoenix.router_dispatch.stop.duration",
count_over_time("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "GraphQL requests count per operation",
keep: graphql_keep,
tag_values: graphql_tag_values,
tags: [:operation_name],
unit: {:native, :millisecond},
ui_options: [unit: " requests"],
reporter_options: [report_as: "graphql", class: "col-span-4"]
reporter_options: [class: "col-span-4"]
),
counter("phoenix.router_dispatch.stop.duration",
counter("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "Count GraphQL requests by operation",
keep: graphql_keep,
tag_values: graphql_tag_values,
tags: [:operation_name],
unit: {:native, :millisecond},
ui_options: [unit: " requests"],
reporter_options: [report_as: "graphql", class: "col-span-4"]
reporter_options: [class: "col-span-4"]
),
average("phoenix.router_dispatch.stop.duration",
average("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "GraphQL requests duration per operation",
keep: graphql_keep,
tag_values: graphql_tag_values,
tags: [:operation_name],
unit: {:native, :millisecond},
reporter_options: [report_as: "graphql", class: "col-span-4"]
reporter_options: [class: "col-span-4"]
),
distribution("phoenix.router_dispatch.stop.duration",
distribution("graphql.router_dispatch.duration",
event_name: [:phoenix, :router_dispatch, :stop],
description: "GraphQL requests duration",
keep: graphql_keep,
unit: {:native, :millisecond},
reporter_options: [report_as: "graphql", buckets: [0, 100, 500, 2000]]
reporter_options: [buckets: [0, 100, 500, 2000]]
)
]
end

View File

@ -0,0 +1,20 @@
defmodule Accent.GraphQL.ErrorReporting do
require Logger
def run(%{result: %{errors: errors}, source: source} = blueprint, _) when not is_nil(errors) do
Logger.error("""
#{operation_name(Absinthe.Blueprint.current_operation(blueprint))}
Errors: #{inspect(errors)}
Source: #{inspect(source)}
""")
{:ok, blueprint}
end
def run(blueprint, _) do
{:ok, blueprint}
end
defp operation_name(nil), do: nil
defp operation_name(operation), do: operation.name
end

View File

@ -1,6 +1,8 @@
defmodule Accent.GraphQL.Helpers.Authorization do
import Accent.GraphQL.Plugins.Authorization
alias Accent.AccessToken
alias Accent.{
Collaborator,
Comment,
@ -140,6 +142,19 @@ defmodule Accent.GraphQL.Helpers.Authorization do
end
end
def api_token_authorize(action, func) do
fn _, args, info ->
access_token =
AccessToken
|> Repo.get(args.id)
|> Repo.preload(user: :collaborations)
project_id = List.first(access_token.user.collaborations).project_id
authorize(action, project_id, info, do: func.(access_token, args, info))
end
end
def integration_authorize(action, func) do
fn _, args, info ->
integration =

View File

@ -0,0 +1,24 @@
defmodule Accent.GraphQL.Mutations.APIToken do
use Absinthe.Schema.Notation
import Accent.GraphQL.Helpers.Authorization
alias Accent.GraphQL.Resolvers.APIToken, as: APITokenResolver
object :api_token_mutations do
field :create_api_token, :mutated_api_token do
arg(:project_id, non_null(:id))
arg(:name, non_null(:string))
arg(:picture_url, :string)
arg(:permissions, list_of(non_null(:string)))
resolve(project_authorize(:create_project_api_token, &APITokenResolver.create/3, :project_id))
end
field :revoke_api_token, :boolean do
arg(:id, non_null(:id))
resolve(api_token_authorize(:revoke_project_api_token, &APITokenResolver.revoke/3))
end
end
end

View File

@ -1,26 +0,0 @@
defmodule Accent.GraphQL.Resolvers.AccessToken do
import Ecto.Query, only: [from: 2]
alias Accent.{
AccessToken,
Plugs.GraphQLContext,
Project,
Repo
}
@spec show_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, AccessToken.t() | nil}
def show_project(project, _, _) do
from(
access_token in AccessToken,
inner_join: user in assoc(access_token, :user),
inner_join: collaboration in assoc(user, :collaborations),
where: collaboration.project_id == ^project.id,
where: user.bot == true
)
|> Repo.one()
|> case do
%AccessToken{token: token} -> {:ok, token}
_ -> {:ok, nil}
end
end
end

View File

@ -0,0 +1,30 @@
defmodule Accent.GraphQL.Resolvers.APIToken do
alias Accent.{
AccessToken,
APITokenManager,
Plugs.GraphQLContext,
Project
}
@spec create(Project.t(), any(), GraphQLContext.t()) :: {:ok, AccessToken.t() | nil}
def create(project, args, info) do
case APITokenManager.create(project, info.context[:conn].assigns[:current_user], args) do
{:ok, %{access_token: api_token}} ->
{:ok, %{api_token: api_token, errors: nil}}
{:error, _reason, _, _} ->
{:ok, %{access_token: nil, errors: ["unprocessable_entity"]}}
end
end
@spec revoke(Project.t(), any(), GraphQLContext.t()) :: {:ok, AccessToken.t() | nil}
def revoke(access_token, _args, _) do
APITokenManager.revoke(access_token)
{:ok, true}
end
@spec list_project(Project.t(), any(), GraphQLContext.t()) :: {:ok, AccessToken.t() | nil}
def list_project(project, _, _) do
{:ok, APITokenManager.list(project)}
end
end

View File

@ -8,6 +8,7 @@ defmodule Accent.GraphQL.Schema do
# Types
import_types(AbsintheErrorPayload.ValidationMessageTypes)
import_types(Accent.GraphQL.Types.APIToken)
import_types(Accent.GraphQL.Types.AuthenticationProvider)
import_types(Accent.GraphQL.Types.DocumentFormat)
import_types(Accent.GraphQL.Types.Role)
@ -54,6 +55,7 @@ defmodule Accent.GraphQL.Schema do
mutation do
# Mutation types
import_types(Accent.GraphQL.Mutations.APIToken)
import_types(Accent.GraphQL.Mutations.Translation)
import_types(Accent.GraphQL.Mutations.Comment)
import_types(Accent.GraphQL.Mutations.Collaborator)
@ -64,6 +66,7 @@ defmodule Accent.GraphQL.Schema do
import_types(Accent.GraphQL.Mutations.Operation)
import_types(Accent.GraphQL.Mutations.Version)
import_fields(:api_token_mutations)
import_fields(:comment_mutations)
import_fields(:translation_mutations)
import_fields(:collaborator_mutations)
@ -107,4 +110,13 @@ defmodule Accent.GraphQL.Schema do
def middleware(middleware, _, _) do
[NewRelic.Absinthe.Middleware] ++ middleware
end
def absinthe_pipeline(config, opts) do
config
|> Absinthe.Plug.default_pipeline(opts)
|> Absinthe.Pipeline.insert_after(
Absinthe.Phase.Document.Result,
Accent.GraphQL.ErrorReporting
)
end
end

View File

@ -0,0 +1,13 @@
defmodule Accent.GraphQL.Types.APIToken do
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
object :api_token do
field(:id, non_null(:id))
field(:token, non_null(:id))
field(:custom_permissions, list_of(non_null(:string)))
field(:user, non_null(:user), resolve: dataloader(Accent.User))
field(:inserted_at, non_null(:datetime))
end
end

View File

@ -21,6 +21,11 @@ defmodule Accent.GraphQL.Types.MutationResult do
field(:errors, list_of(:string))
end
object :mutated_api_token do
field(:api_token, :api_token)
field(:errors, list_of(:string))
end
object :mutated_collaborator do
field(:collaborator, :collaborator)
field(:errors, list_of(:string))

View File

@ -45,8 +45,8 @@ defmodule Accent.GraphQL.Types.Project do
resolve(project_authorize(:lint, &Accent.GraphQL.Resolvers.Project.lint_translations/3))
end
field :access_token, :string do
resolve(project_authorize(:show_project_access_token, &Accent.GraphQL.Resolvers.AccessToken.show_project/3))
field :api_tokens, list_of(non_null(:api_token)) do
resolve(project_authorize(:list_project_api_tokens, &Accent.GraphQL.Resolvers.APIToken.list_project/3))
end
field :viewer_permissions, list_of(:string) do

View File

@ -79,6 +79,13 @@ defmodule Movement.Persisters.Base do
|> assign_revision(assigns[:revision])
|> assign_version(assigns[:version])
placeholder_values =
Map.new(
Enum.map(placeholders, fn {key, _value} ->
{key, {:placeholder, key}}
end)
)
operations =
context.operations
|> Stream.map(fn operation ->
@ -86,12 +93,12 @@ defmodule Movement.Persisters.Base do
operation
| inserted_at: {:placeholder, :now},
updated_at: {:placeholder, :now},
user_id: {:placeholder, :user_id},
document_id: {:placeholder, :document_id},
project_id: {:placeholder, :project_id},
batch_operation_id: {:placeholder, :batch_operation_id},
version_id: operation.version_id || {:placeholder, :version_id},
revision_id: operation.revision_id || {:placeholder, :revision_id}
user_id: placeholder_values[:user_id] || operation.user_id,
document_id: placeholder_values[:document_id] || operation.document_id,
project_id: placeholder_values[:project_id] || operation.project_id,
batch_operation_id: placeholder_values[:batch_operation_id] || operation.batch_operation_id,
version_id: operation.version_id || placeholder_values[:version_id],
revision_id: operation.revision_id || placeholder_values[:revision_id]
})
end)
|> Stream.chunk_every(@operations_inserts_chunk)
@ -127,18 +134,28 @@ defmodule Movement.Persisters.Base do
{context, Migrator.down([operation])}
end
defp assign_project(placeholders, nil), do: placeholders
defp assign_project(placeholders, project),
do: Map.put(placeholders, :project_id, project && project.id)
defp assign_batch_operation(placeholders, nil), do: placeholders
defp assign_batch_operation(placeholders, batch_operation),
do: Map.put(placeholders, :batch_operation_id, batch_operation && batch_operation.id)
defp assign_document(placeholders, nil), do: placeholders
defp assign_document(placeholders, document),
do: Map.put(placeholders, :document_id, document && document.id)
defp assign_revision(placeholders, nil), do: placeholders
defp assign_revision(placeholders, revision),
do: Map.put(placeholders, :revision_id, revision && revision.id)
defp assign_version(placeholders, nil), do: placeholders
defp assign_version(placeholders, version),
do: Map.put(placeholders, :version_id, version && version.id)

View File

@ -19,7 +19,7 @@ defmodule Accent.Router do
scope "/graphql" do
pipe_through(:graphql)
forward("/", Absinthe.Plug, schema: Accent.GraphQL.Schema)
forward("/", Absinthe.Plug, schema: Accent.GraphQL.Schema, pipeline: {Accent.GraphQL.Schema, :absinthe_pipeline})
end
pipeline :authenticate do

View File

@ -94,13 +94,13 @@ defmodule Accent.Mixfile do
{:bamboo_smtp, "~> 2.0"},
# Events handling
{:oban, "~> 2.0"},
{:oban, "~> 2.13.0"},
# Metrics and monitoring
{:new_relic_agent, "~> 1.27"},
{:new_relic_absinthe, "~> 0.0"},
{:telemetry, "~> 1.0", override: true},
{:telemetry_ui, "~> 1.1"},
{:telemetry_ui, path: "../mirego/telemetry_ui"},
# Mock testing
{:mox, "~> 0.3", only: :test},

View File

@ -7,7 +7,7 @@
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"canada": {:hex, :canada, "1.0.2", "040e4c47609b0a67d5773ac1fbe5e99f840cef173d69b739beda7c98453e0770", [:mix], [], "hexpm", "4269f74153fe89583fe50bd4d5de57bfe01f31258a6b676d296f3681f1483c68"},
"canary": {:hex, :canary, "1.1.1", "4138d5e05db8497c477e4af73902eb9ae06e49dceaa13c2dd9f0b55525ded48b", [:mix], [{:canada, "~> 1.0.1", [hex: :canada, repo: "hexpm", optional: false]}, {:ecto, ">= 1.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f348d9848693c830a65b707bba9e4dfdd6434e8c356a8d4477e4535afb0d653b"},
"castore": {:hex, :castore, "0.1.20", "62a0126cbb7cb3e259257827b9190f88316eb7aa3fdac01fd6f2dfd64e7f46e9", [:mix], [], "hexpm", "a020b7650529c986c454a4035b6b13a328e288466986307bea3aadb4c95ac98a"},
"castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
@ -59,7 +59,7 @@
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.4", "7b06aefe43efbbee7f33fa0875b409400243e4c96d7bb2f962d0a0c0b5ddacb1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b37778447c9d3e9904899e132d62a7d484fa8abcc10433b1b820a9b907b82852"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.11", "c50eac83dae6b5488859180422dfb27b2c609de87f4aa5b9c926ecd0501cd44f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76c99a0ffb47cd95bf06a917e74f282a603f3e77b00375f3c2dd95110971b102"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
@ -76,7 +76,7 @@
"sentry": {:hex, :sentry, "7.2.5", "570db92c3bbacd6ad02ac81cba8ac5af11235a55d65ac4375e3ec833975b83d3", [:mix], [{:hackney, "~> 1.8 or 1.6.5", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "ea84ed6848505ff2a246567df562f465d2b34c317d3ecba7c7df58daa56e5e5d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"},
"telemetry": {:hex, :telemetry, "1.2.0", "a8ce551485a9a3dac8d523542de130eafd12e40bbf76cf0ecd2528f24e812a44", [:rebar3], [], "hexpm", "1427e73667b9a2002cf1f26694c422d5c905df889023903c4518921d53e3e883"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
"telemetry_ui": {:hex, :telemetry_ui, "1.2.0", "87ab95991e876966584d5071476171782ed0adef839724cd465bdbb35c8ebc99", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.13", [hex: :oban, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}, {:timex, "~> 3.7", [hex: :timex, repo: "hexpm", optional: false]}, {:vega_lite, "~> 0.1", [hex: :vega_lite, repo: "hexpm", optional: false]}], "hexpm", "60613da7d689e68fa36bccb63feb457f3276ca9ecea4342e5ba0e6ccb699406f"},

View File

@ -0,0 +1,11 @@
defmodule Accent.Repo.Migrations.UpgradeTelemetryUi2 do
use Ecto.Migration
def up do
TelemetryUI.Backend.EctoPostgres.Migrations.up(version: 3)
end
def down do
TelemetryUI.Backend.EctoPostgres.Migrations.down(version: 2)
end
end

View File

@ -0,0 +1,9 @@
defmodule Accent.Repo.Migrations.AddCustomPermissionsOnTokens do
use Ecto.Migration
def change do
alter table(:auth_access_tokens) do
add(:custom_permissions, {:array, :string})
end
end
end

View File

@ -1,22 +0,0 @@
defmodule AccentTest.GraphQL.Resolvers.AccessToken do
use Accent.RepoCase
alias Accent.GraphQL.Resolvers.AccessToken, as: Resolver
alias Accent.{
AccessToken,
Collaborator,
Project,
Repo,
User
}
test "show project" do
user = %User{email: "test@example.com", bot: true} |> Repo.insert!()
project = %Project{main_color: "#f00", name: "My project"} |> Repo.insert!()
%Collaborator{project_id: project.id, user_id: user.id, role: "bot"} |> Repo.insert!()
token = %AccessToken{user_id: user.id, token: "foo"} |> Repo.insert!()
assert Resolver.show_project(project, %{}, %{}) == {:ok, token.token}
end
end

View File

@ -39,7 +39,6 @@ defmodule AccentTest.GraphQL.Resolvers.Permission do
{:ok, permissions} = Resolver.list_project(project, %{}, context)
assert :create_slave in permissions
assert :show_project_access_token in permissions
assert :show_project in permissions
end

View File

@ -34,7 +34,7 @@ defmodule AccentTest.ProjectCreator do
params = %{"main_color" => "#f00", "name" => "OK", "language_id" => language.id}
{:ok, project} = ProjectCreator.create(params: params, user: user)
bot_collaborator = project |> Ecto.assoc(:collaborators) |> Ecto.Query.where([c], c.role == "bot") |> Repo.one()
bot_collaborator = project |> Ecto.assoc(:all_collaborators) |> Ecto.Query.where([c], c.role == "bot") |> Repo.one()
bot_user = Repo.preload(bot_collaborator, :user).user
bot_access = Repo.preload(bot_collaborator, user: :access_tokens).user.access_tokens |> hd()

View File

@ -5,15 +5,15 @@ defmodule AccentTest.ProjectDeleter do
test "create with language and user" do
project = %Project{main_color: "#f00", name: "french"} |> Repo.insert!()
collaborator = %Collaborator{project_id: project.id} |> Repo.insert!()
collaborator = %Collaborator{project_id: project.id, role: "reviewer"} |> Repo.insert!()
assert project
|> Ecto.assoc(:collaborators)
|> Ecto.assoc(:all_collaborators)
|> Repo.all()
|> Enum.map(& &1.id) === [collaborator.id]
{:ok, project} = ProjectDeleter.delete(project: project)
assert Repo.all(Ecto.assoc(project, :collaborators)) === []
assert Repo.all(Ecto.assoc(project, :all_collaborators)) === []
end
end

View File

@ -1,6 +1,12 @@
import {isBlank} from '@ember/utils';
import {helper} from '@ember/component/helper';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import {frCA, enUS} from 'date-fns/locale';
const LOCALES = {
'fr-ca': frCA,
'en-us': enUS,
} as any;
const OPTIONS = {
addSuffix: true,
@ -9,8 +15,14 @@ const OPTIONS = {
const timeAgoInWords = ([date]: [string]) => {
if (isBlank(date)) return '';
const locale = LOCALES[localStorage.getItem('locale') || 'en-us'] || enUS;
return formatDistanceToNow(new Date(date), OPTIONS);
const options = {
locale,
...OPTIONS,
};
return formatDistanceToNow(new Date(date), options);
};
export default helper(timeAgoInWords);

View File

@ -361,7 +361,7 @@
"collaborators_text": "Manage who has access to what features of this project",
"badges": "Badges",
"badges_text": "Quick look on project stats, embedded in images",
"api_token": "API Token",
"api_token": "API Tokens",
"api_token_text": "Integrate Accent workflow with the CLI or plain API requests",
"service_integrations": "Service & integrations",
"service_integrations_text": "Notify and interact with external systems based on Accent events",
@ -400,13 +400,20 @@
"use_language_text": "By switching to the Accent language using your favorite framework i18n tool, the page will display the strings in the pseudo language files and the Accent script will replace those by the strings in Accent. It will also bind click event on the elements so that you can select strings directly in your app and edit them via Accent."
},
"api_token": {
"title": "API token",
"text_1": "With this token, you can make authentified calls to Accents API. All operations will be flagged as \"made by the API client\".",
"text_2": "Typically, this is used to sync and add translations the localization files in a deploy script."
"title": "API tokens",
"text_1": "With this token, you can make authentified calls to Accents API. All tokens have the 'bot' permissions: sync and add translations. You can specifiy custom permissions to have more fine grained tokens across your project. Set the token as <code>ACCENT_API_KEY</code> in your GitHub actions workflows to be authenticated using accent-cli.",
"revoke_button": "Revoke",
"revoke_confirm": "Are you sure you want to revoke the token? This operation cant be reverted.",
"inserted_at": "Created:",
"create_title": "Add a new API token",
"create_button": "Create",
"create_name_placeholder": "Display name",
"create_picture_url_placeholder": "Avatar URL (optional)"
},
"user_token": {
"title": "Your personal token",
"text_1": "You can also use your personal token to make API call as yourself. Beware, this token is global for all your project."
"text_1": "You can also use your personal token to make API call as yourself. Beware, this token is global for all your project.",
"text_shell": "You can set a global environment variable named <code>ACCENT_API_KEY</code> on your shell (bash, zsh, fish, etc) to be authenticated when you use accent-cli."
},
"badges": {
"title": "Badges",
@ -562,7 +569,7 @@
"error": "Invalid project",
"cancel_button": "Cancel",
"language_label": "Master language:",
"language_search_placeholder": "Search languages…",
"language_search_placeholder": "Search…",
"name_label": "Name:",
"save_button": "Create"
},
@ -662,7 +669,7 @@
"conflicted_label": "in review",
"master_label": "master",
"save_button": "Save",
"last_updated_label": "Last updated: ",
"last_updated_label": "Last updated:",
"new_language_link": "New language",
"no_related_translations": "No translations yet. You need to add another language to your project."
},
@ -671,7 +678,7 @@
},
"project_manage_languages_create_form": {
"default_null": "Initialize language with empty strings",
"language_search_placeholder": "Search languages…",
"language_search_placeholder": "Search…",
"save_button": "Add language"
},
"project_manage_languages_overview": {
@ -825,7 +832,7 @@
"in_review_label": "in review",
"in_review_tooltip": "View in review",
"lint_messages_label": "{count, plural, =0 {No linting warnings} =1 {1 linting warning} other {# linting warnings}}",
"last_updated_label": "Last updated: ",
"last_updated_label": "Last updated:",
"maybe_sync_before": "Maybe try to",
"maybe_sync_link": "sync some files →",
"no_translations": "Looks like no strings were added for your project.",
@ -843,7 +850,7 @@
"manage_languages_text": "Target languages follow your master language strings and conflicts",
"add_collaborator": "Add collaborator",
"add_collaborator_text": "Translators, developers, etc.",
"api_token": "Get your API Token",
"api_token": "Get an API Token",
"api_token_text": "Interact with the API from your project",
"start_review": "Start to review and translate",
"export": "Export the corrected file",
@ -858,6 +865,7 @@
"ADMIN": "Admin",
"DEVELOPER": "Developer",
"OWNER": "Owner",
"BOT": "API Token",
"REVIEWER": "Reviewer"
},
"integration_services": {
@ -977,7 +985,11 @@
"loading_content": "Fetching projects badges…"
},
"api_token": {
"loading_content": "Fetching projects API settings…"
"loading_content": "Fetching projects API settings…",
"api_token_add_success": "API token has been created with success",
"api_token_add_error": "API token could not be created",
"api_token_revoke_success": "API token has been revoked with success",
"api_token_revoke_error": "API token could not be revoked"
},
"jipt": {
"loading_content": "Fetching projects Just In Place Translations settings…"

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,8 @@ export default class ApplicationRoute extends Route {
router: RouterService;
async beforeModel() {
this.intl.setLocale('en-us');
const locale = localStorage.getItem('locale') || 'en-us';
this.intl.setLocale(locale);
raven.config(config.SENTRY.DSN).install();

View File

@ -2,9 +2,13 @@ import Component from '@glimmer/component';
import {tracked} from '@glimmer/tracking';
import {action} from '@ember/object';
export default class AccAvatarImg extends Component {
interface Args {
showFallback?: boolean;
}
export default class AccAvatarImg extends Component<Args> {
@tracked
showFallback = false;
showFallback = this.args.showFallback || false;
@action
fallbackImage() {

View File

@ -17,7 +17,7 @@
<div local-class="item-header">
<div local-class="item-header-content">
{{#if @activity.user.isBot}}
<span local-class="item-user item-user--bot">
<span local-class="item-user">
{{@activity.user.fullname}}
</span>
{{else}}

View File

@ -1,7 +1,13 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import config from 'accent-webapp/config/environment';
import {inject as service} from '@ember/service';
import IntlService from 'ember-intl/services/intl';
export default class ApplicationFooter extends Component {
@service('intl')
intl: IntlService;
// The version is replaced at runtime when served by the API.
// If the webapp is not served by the API (like in development),
// the version tag will show up as 'dev'.
@ -9,6 +15,10 @@ export default class ApplicationFooter extends Component {
return config.version === '__VERSION__' ? 'dev' : config.version;
}
get currentLocale() {
return localStorage.getItem('locale') || 'en-us';
}
toggleDark() {
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
@ -18,4 +28,12 @@ export default class ApplicationFooter extends Component {
document.documentElement.setAttribute('data-theme', 'light');
localStorage.setItem('theme', 'light');
}
@action
changeLanguage(event: any) {
const locale = event.target.value;
localStorage.setItem('locale', locale);
this.intl.setLocale(locale);
}
}

View File

@ -14,11 +14,42 @@
max-width: var(--screen-lg);
}
.meta {
.left {
display: flex;
align-items: center;
}
.right {
display: flex;
align-items: center;
gap: 10px;
}
.select {
position: relative;
font-size: 11px;
select {
appearance: none;
border: 1px solid var(--background-light-highlight);
border-radius: 3px;
padding: 2px 15px 2px 4px;
}
}
.select::after {
display: block;
pointer-events: none;
cursor: pointer;
content: '';
position: absolute;
top: 50%;
right: 6px;
font-size: 140%;
transform: translateY(-50%) rotate(90deg);
color: var(--text-color-normal);
}
.version {
font-family: var(--font-monospace);
font-size: 10px;

View File

@ -1,6 +1,6 @@
<footer local-class="footer">
<div local-class="inner">
<div local-class="meta">
<div local-class="left">
<span local-class="version">
{{this.version}}
</span>
@ -15,11 +15,20 @@
</div>
</div>
<div>
{{t "components.application_footer.text"}}
<a local-class="external-link" target="_blank" rel="noopener" href="https://mirego.com?ref=accent">
{{t "general.company_name"}}
</a>
<div local-class="right">
<div>
{{t "components.application_footer.text"}}
<a local-class="external-link" target="_blank" rel="noopener" href="https://mirego.com?ref=accent">
{{t "general.company_name"}}
</a>
</div>
<div local-class="select">
<select {{on "change" this.changeLanguage}}>
<option selected={{eq this.currentLocale 'en-us'}} value="en-us">En</option>
<option selected={{eq this.currentLocale 'fr-ca'}} value="fr-ca">Fr</option>
</select>
</div>
</div>
</div>
</footer>

View File

@ -62,6 +62,7 @@
{{#if this.file}}
<div>
{{#if @documents}}
{{#if this.isSync}}
<div local-class="option">
<p local-class="textHelper">
{{t "components.commit_file.file_source"}}
@ -79,6 +80,7 @@
</p>
<Input @value={{this.documentPath}} local-class="fileSourceName" />
</div>
{{/if}}
{{/if}}
<div local-class="option">

View File

@ -8,6 +8,7 @@
margin-bottom: 20px;
margin-right: 20px;
border-radius: 3px;
border: 1px solid var(--background-light-highlight);
&:nth-child(even) {
flex: 1 1 50%;
@ -59,7 +60,7 @@
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 6px;
padding: 8px 10px;
color: var(--color-grey);
}
@ -97,8 +98,8 @@
transition: 0.2s ease-in-out;
transition-property: opacity;
position: absolute;
right: 0;
top: 0;
right: 3px;
top: 3px;
display: flex;
align-items: center;
justify-content: center;
@ -124,19 +125,18 @@
}
.actions {
margin-top: 15px;
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.actionItem-text {
margin-bottom: 7px;
font-size: 12px;
color: var(--color-grey);
}
.actionItem-button {
margin-top: 10px;
}
@media (max-width: 440px) {
.language-reviewedPercentage {
font-size: 18px;

View File

@ -147,7 +147,7 @@
}
.master {
margin: 0 0 20px;
margin: 0 0 10px;
}
.stats-title-links {
@ -220,8 +220,4 @@
.dashboard-revisions > .content {
margin: 0;
}
.stats-title {
display: none;
}
}

View File

@ -1,7 +1,7 @@
.empty-content {
padding: 15px 16px 16px;
width: 100%;
max-width: 500px;
max-width: 520px;
color: hsl(var(--color-primary-hue), var(--color-primary-saturation), 30%);
font-size: 13px;
font-weight: 300;

View File

@ -1,8 +1,107 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
import {dropTask} from 'ember-concurrency';
import {taskFor} from 'ember-concurrency-ts';
import IntlService from 'ember-intl/services/intl';
import {CreateApiTokenResponse} from 'accent-webapp/queries/create-api-token';
interface Args {
projectToken: string;
userToken: string;
onCreate: (args: {
name: string;
pictureUrl: string | null;
permissions: string[];
}) => void;
onRevoke: (args: {id: string}) => void;
}
export default class APIToken extends Component<Args> {}
export default class APIToken extends Component<Args> {
@service('intl')
intl: IntlService;
@tracked
isEdit = false;
@tracked
showPermissionsInput = false;
@tracked
apiTokenName = '';
@tracked
apiTokenPermissions: string[] = [];
@tracked
apiTokenPictureUrl: string | null = null;
@action
onToggleForm() {
this.isEdit = !this.isEdit;
}
get isSubmitting() {
return taskFor(this.submitTask).isRunning;
}
get isSubmitDisabled() {
return !this.apiTokenName || this.apiTokenName.length === 0;
}
@dropTask
*submitTask() {
if (!this.apiTokenName) return;
const response: CreateApiTokenResponse = yield this.args.onCreate({
name: this.apiTokenName,
pictureUrl: this.apiTokenPictureUrl,
permissions: this.apiTokenPermissions,
});
console.log(response);
if (response.apiToken) {
this.apiTokenName = '';
this.apiTokenPictureUrl = '';
this.showPermissionsInput = false;
this.unselectAllPermissions();
}
}
@action
changePermission() {
this.apiTokenPermissions = Array.from(
document.querySelectorAll('input[name="permiss"]:checked')
).map((input: HTMLInputElement) => input.value);
}
@action
togglePermissionsInput() {
this.showPermissionsInput = !this.showPermissionsInput;
}
@action
selectAllPermissions() {
Array.from(document.querySelectorAll('input[name="permiss"]')).forEach(
(input: HTMLInputElement) => (input.checked = true)
);
}
@action
unselectAllPermissions() {
Array.from(document.querySelectorAll('input[name="permiss"]')).forEach(
(input: HTMLInputElement) => (input.checked = false)
);
}
@action
apiTokenNameChanged(event: any) {
this.apiTokenName = event.target.value;
}
@action
apiTokenPictureUrlChanged(event: any) {
this.apiTokenPictureUrl = event.target.value;
}
}

View File

@ -0,0 +1,43 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
import {dropTask} from 'ember-concurrency';
import {taskFor} from 'ember-concurrency-ts';
import IntlService from 'ember-intl/services/intl';
interface Args {
token: any;
onRevoke: (args: {id: string}) => void;
}
export default class APITokenItem extends Component<Args> {
@service('intl')
intl: IntlService;
@tracked
showPermissions = false;
get isRevoking() {
return taskFor(this.revokeTask).isRunning;
}
@dropTask
*revokeTask() {
const message = this.intl.t(
'components.project_settings.api_token.revoke_confirm'
);
// eslint-disable-next-line no-alert
if (!window.confirm(message)) {
return;
}
yield this.args.onRevoke(this.args.token);
}
@action
togglePermissions() {
this.showPermissions = !this.showPermissions;
}
}

View File

@ -0,0 +1,111 @@
.api-token {
display: flex;
flex-direction: column;
gap: 0;
}
.api-token:hover .revoke-button {
opacity: 1;
}
.api-token-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.api-token-meta {
display: flex;
align-items: center;
gap: 7px;
}
.api-token-user {
display: flex;
align-items: center;
gap: 4px;
}
.api-token-name {
font-weight: bold;
font-size: 12px;
}
.api-token-inserted {
opacity: 0.3;
font-size: 10px;
}
.api-token-permissions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.api-token-permission {
font-size: 11px;
font-family: var(--font-monospace);
}
.revoke-button {
opacity: 0;
transition: 0.2s ease-in-out;
transition-property: opacity;
padding: 2px 4px !important;
}
.token {
display: inline-block;
width: 100%;
margin: 0;
padding: 2px 8px;
overflow-x: scroll;
word-break: keep-all;
background: var(--background-light);
font-family: var(--font-monospace);
font-size: 11px;
border-radius: 8px;
transition-property: border-color, box-shadow;
transition: 0.3s ease-in-out;
color: var(--text-color-normal);
&:focus {
outline: none;
border-color: var(--color-primary-opacity-70);
box-shadow: 0 0 3px 2px var(--color-primary-opacity-10);
&::-moz-selection {
background: var(--color-primary-opacity-10);
}
&::selection {
background: var(--color-primary-opacity-10);
}
}
}
.toggle-permissions {
display: inline-flex;
align-items: center;
gap: 5px;
background: transparent;
padding: 3px 0;
font-size: 12px;
font-weight: bold;
opacity: 0.7;
}
.toggle-permissions-icon {
display: block;
font-size: 10px;
transition: 0.2s ease-in-out;
transition-property: transform;
}
.toggle-permissions--open {
opacity: 1;
}
.toggle-permissions--open .toggle-permissions-icon {
transform: rotate(180deg);
}

View File

@ -0,0 +1,52 @@
<div local-class="api-token">
<div local-class="api-token-header">
<div local-class="api-token-meta">
<div local-class="api-token-user">
{{#if @token.user.pictureUrl}}
<AccAvatarImg local-class="picture" src={{@token.user.pictureUrl}} />
{{else}}
<AccAvatarImg local-class="picture" src={{@token.user.pictureUrl}} @showFallback={{true}} />
{{/if}}
<span local-class="api-token-name">{{@token.user.fullname}}</span>
</div>
<span local-class="api-token-inserted">
{{t "components.project_settings.api_token.inserted_at"}}
<TimeAgoInWordsTag @date={{@token.insertedAt}} />
</span>
</div>
<div>
{{#if (get @permissions "revoke_project_api_token")}}
<AsyncButton
@onClick={{perform this.revokeTask}}
@loading={{this.isRevoking}}
@disabled={{this.isRevoking}}
local-class="revoke-button"
class="button button--small button--filled button--red"
>
{{inline-svg "/assets/x.svg" class="button-icon"}}
{{t "components.project_settings.api_token.revoke_button"}}
</AsyncButton>
{{/if}}
</div>
</div>
<input readonly="" onClick="this.select();" local-class="token" value={{@token.token}}>
{{#if @token.customPermissions}}
<button
{{on "click" (fn this.togglePermissions)}}
local-class="toggle-permissions {{if this.showPermissions 'toggle-permissions--open'}}"
>Custom permissions
<span local-class="toggle-permissions-icon">↓</span>
</button>
{{#if this.showPermissions}}
<ul local-class="api-token-permissions">
{{#each @token.customPermissions as |permission|}}
<li local-class="api-token-permission">{{permission}}</li>
{{/each}}
</ul>
{{/if}}
{{/if}}
</div>

View File

@ -1,31 +1,225 @@
.project-settings-api-token {
display: flex;
gap: 35px;
align-items: flex-start;
margin-top: 30px;
}
.text {
max-width: 490px;
margin: 10px 0 4px;
margin: 10px 0 15px;
font-size: 13px;
font-style: italic;
}
.text code {
font-family: var(--font-monospace);
padding: 1px 0;
color: var(--color-primary);
}
.tokens {
width: 100%;
}
.user-token {
margin-top: 30px;
width: 100%;
padding: 20px;
border-radius: 3px;
background-color: var(--background-light);
}
.user-token .token {
background-color: var(--background-light-highlight);
}
.user-token h2 {
font-size: 14px;
}
.api-tokens {
display: flex;
flex-direction: column;
gap: 22px;
}
.api-tokens.overlay {
position: relative;
pointer-events: none;
&::after {
content: '';
background: rgba(#fff, 0.5);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
.api-token {
display: flex;
flex-direction: column;
gap: 0;
}
.api-token:hover .revoke-button {
opacity: 1;
}
.api-token-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.api-token-meta {
display: flex;
align-items: center;
gap: 7px;
}
.api-token-user {
display: flex;
align-items: center;
gap: 4px;
}
.api-token-name {
font-weight: bold;
font-size: 12px;
}
.api-token-inserted {
opacity: 0.3;
font-size: 10px;
}
.api-token-permissions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.api-token-permissions-label {
font-size: 12px;
font-weight: bold;
opacity: 0.7;
}
.api-token-permission {
padding: 1px 3px;
border-radius: 3px;
background-color: var(--background-light);
border: 1px solid var(--background-light-highlight);
font-size: 11px;
font-family: var(--font-monospace);
}
.revoke-button {
opacity: 0;
transition: 0.2s ease-in-out;
transition-property: opacity;
padding: 2px 4px !important;
}
.create-button {
margin-top: 8px;
}
.permissions-inputs {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.permissions-input {
font-size: 12px;
font-family: var(--font-monospace);
}
.toggle-permissions-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.toggle-permissions-header-button {
background: transparent;
border: none;
color: var(--color-primary);
}
.toggle-permissions-input {
display: flex;
align-items: center;
gap: 5px;
background: transparent;
border-bottom: 1px solid var(--text-color-normal);
padding: 3px 0;
font-size: 12px;
font-weight: bold;
opacity: 0.7;
}
.toggle-permissions-input-icon {
display: block;
font-size: 10px;
transition: 0.2s ease-in-out;
transition-property: transform;
}
.toggle-permissions-input--open {
opacity: 1;
}
.toggle-permissions-input--open .toggle-permissions-input-icon {
transform: rotate(180deg);
}
.picture {
width: 14px;
height: 14px;
object-fit: cover;
border-radius: 3px;
}
.form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: 36px;
padding: 16px;
border-radius: 4px;
background: var(--background-light);
border: 1px solid var(--body-background-border);
}
.form h2 {
font-size: 14px;
}
.textInput {
@extend %textInput;
padding: 4px 10px;
margin-right: 5px;
width: 100%;
font-family: var(--font-primary);
font-size: 12px;
}
.token {
display: inline-block;
width: 100%;
max-width: 640px;
margin: 10px 0 0;
padding: 8px;
margin: 0;
padding: 2px 8px;
overflow-x: scroll;
word-break: keep-all;
background: var(--background-light);
font-family: var(--font-monospace);
font-size: 11px;
border: 2px solid var(--background-light-highlight);
border-radius: 3px;
border-radius: 8px;
transition-property: border-color, box-shadow;
transition: 0.3s ease-in-out;
color: var(--text-color-normal);
@ -44,3 +238,9 @@
}
}
}
@media (max-width: 840px) {
.project-settings-api-token {
flex-direction: column;
}
}

View File

@ -1,15 +1,100 @@
<div local-class="project-settings-api-token">
{{#if (get @permissions "list_project_api_tokens")}}
<div local-class="tokens">
<ProjectSettings::Title
@icon="/assets/code.svg"
@title={{t "components.project_settings.api_token.title"}}
/>
<p local-class="text">
{{t "components.project_settings.api_token.text_1"}}
{{t "components.project_settings.api_token.text_2"}}
{{{t "components.project_settings.api_token.text_1"}}}
</p>
<input readonly="" onClick="this.select();" local-class="token" value={{@projectToken}}>
<div local-class="api-tokens {{if this.isRevoking 'overlay'}}">
{{#each @projectTokens key="id" as |token|}}
<ProjectSettings::ApiToken::Item @token={{token}} @permissions={{@permissions}} @onRevoke={{@onRevoke}}/>
{{/each}}
</div>
{{#if (get @permissions "create_project_api_token")}}
<div local-class="form">
<ProjectSettings::Title
@title={{t "components.project_settings.api_token.create_title"}}
/>
<input
{{on-key "cmd+Enter" (perform this.submitTask)}}
{{on "input" (fn this.apiTokenNameChanged)}}
local-class="textInput"
type="text"
value={{this.apiTokenName}}
placeholder={{t "components.project_settings.api_token.create_name_placeholder"}}
>
<input
{{on-key "cmd+Enter" (perform this.submitTask)}}
{{on "input" (fn this.apiTokenPictureUrlChanged)}}
local-class="textInput"
type="url"
value={{this.apiTokenPictureUrl}}
placeholder={{t "components.project_settings.api_token.create_picture_url_placeholder"}}
>
<div local-class="toggle-permissions-header">
<button
{{on "click" (fn this.togglePermissionsInput)}}
local-class="toggle-permissions-input {{if this.showPermissionsInput 'toggle-permissions-input--open'}}"
>Use custom permissions
<span local-class="toggle-permissions-input-icon">↓</span>
</button>
{{#if this.showPermissionsInput}}
<div>
<button
{{on "click" (fn this.selectAllPermissions)}}
local-class="toggle-permissions-header-button"
>Select all
</button>
<button
{{on "click" (fn this.unselectAllPermissions)}}
local-class="toggle-permissions-header-button"
>Unselect all
</button>
</div>
{{/if}}
</div>
{{#if this.showPermissionsInput}}
<ul local-class="permissions-inputs">
{{#each-in @permissions as |permission|}}
<li local-class="permissions-input">
<label for={{concat "permission-" permission}}>
<input name="permiss" value={{permission}} type="checkbox"
id={{concat "permission-" permission}}
{{on "input" (fn this.changePermission)}}
/>
{{permission}}
</label>
</li>
{{/each-in}}
</ul>
{{/if}}
<AsyncButton
local-class="create-button"
class="button button--filled"
@disabled={{this.isSubmitDisabled}}
@onClick={{perform this.submitTask}}
@loading={{this.isSubmitting}}
>
{{t "components.project_settings.api_token.create_button"}}
</AsyncButton>
</div>
{{/if}}
</div>
{{/if}}
<div local-class="user-token">
<ProjectSettings::Title
@ -21,6 +106,10 @@
{{t "components.project_settings.user_token.text_1"}}
</p>
<p local-class="text">
{{{t "components.project_settings.user_token.text_shell"}}}
</p>
<input readonly="" onClick="this.select();" local-class="token" value={{@userToken}}>
</div>
</div>

View File

@ -22,7 +22,7 @@
.badge-title {
margin: 0 10px 0 0;
font-size: 14px;
font-weight: 300;
font-weight: bold;
color: var(--color-black);
}

View File

@ -1,12 +1,14 @@
<div local-class="project-settings-collaborators">
<div local-class="columns">
<div local-class="columns-item">
{{#if (get @permissions "create_collaborator")}}
<div local-class="createForm">
<ProjectSettings::Collaborators::CreateForm
@project={{@project}}
@onCreate={{@onCreateCollaborator}}
/>
</div>
{{/if}}
<ProjectSettings::Collaborators::List
@permissions={{@permissions}}

View File

@ -18,14 +18,13 @@ interface Args {
export default class GitHub extends Component<Args> {
get webhookUrl() {
if (!this.args.project.accessToken) return;
const host = window.location.origin;
return `${host}${fmt(
config.API.HOOKS_PATH,
'github',
this.args.project.id,
this.args.project.accessToken
'<YOUR_API_TOKEN_HERE>'
)}`;
}

View File

@ -48,6 +48,7 @@ export default class ProjectsHeader extends Component<Args> {
@restartableTask
*debounceQuery(query: string) {
this.debouncedQuery = query;
if (!this.debouncedQuery) return;
yield timeout(DEBOUNCE_OFFSET);

View File

@ -1,5 +1,5 @@
.projects-header {
padding: 64px 0 20px;
padding: 40px 0 20px;
background-image: linear-gradient(
0,
var(--body-background),
@ -8,8 +8,6 @@
}
.projects-header.withProject {
padding-top: 59px;
.applicationLogo {
position: absolute;
padding-right: 10px;
@ -143,7 +141,7 @@
.search-icon {
position: absolute;
top: 8px;
top: 6px;
left: 10px;
width: 15px;
height: 15px;

View File

@ -1,16 +1,16 @@
.progress-bar {
width: 100%;
height: 2px;
height: 5px;
background: var(--background-light);
border-radius: 3px;
border-radius: 1px;
box-shadow: 0 1px 8px var(--shadow-color);
}
.progress {
height: 2px;
height: 5px;
width: 0;
background: currentColor;
border-radius: 3px;
border-radius: 1px;
transition: width 500ms ease-in-out;
&:after {

View File

@ -13,7 +13,7 @@
.label {
display: flex;
align-items: center;
align-items: flex-start;
width: 100%;
max-width: 250px;
margin-top: 10px;

View File

@ -13,7 +13,7 @@
.label {
display: flex;
align-items: center;
align-items: flex-start;
width: 100%;
max-width: 250px;
margin-top: 10px;

View File

@ -1,28 +1,112 @@
import {inject as service} from '@ember/service';
import {action} from '@ember/object';
import {readOnly, equal, and} from '@ember/object/computed';
import Controller from '@ember/controller';
import IntlService from 'ember-intl/services/intl';
import GlobalState from 'accent-webapp/services/global-state';
import FlashMessages from 'ember-cli-flash/services/flash-messages';
import ApolloMutate from 'accent-webapp/services/apollo-mutate';
import apiTokenCreateQuery from 'accent-webapp/queries/create-api-token';
import apiTokenRevokeQuery from 'accent-webapp/queries/revoke-api-token';
const FLASH_MESSAGE_PREFIX = 'pods.project.edit.flash_messages.';
const FLASH_MESSAGE_API_TOKEN_ADD_SUCCESS = `${FLASH_MESSAGE_PREFIX}api_token_add_success`;
const FLASH_MESSAGE_API_TOKEN_ADD_ERROR = `${FLASH_MESSAGE_PREFIX}api_token_add_error`;
const FLASH_MESSAGE_API_TOKEN_REVOKE_SUCCESS = `${FLASH_MESSAGE_PREFIX}api_token_revoke_success`;
const FLASH_MESSAGE_API_TOKEN_REVOKE_ERROR = `${FLASH_MESSAGE_PREFIX}api_token_revoke_error`;
export default class APITokenController extends Controller {
@service('intl')
intl: IntlService;
@service('flash-messages')
flashMessages: FlashMessages;
@service('global-state')
globalState: GlobalState;
@service('apollo-mutate')
apolloMutate: ApolloMutate;
@readOnly('globalState.permissions')
permissions: any;
@readOnly('model.project')
project: any;
@readOnly('model.accessToken')
accessToken: string;
@readOnly('globalState.permissions')
permissions: any;
@readOnly('model.apiTokens')
apiTokens: any;
@equal('model.project.name', undefined)
emptyData: boolean;
@and('emptyData', 'model.loading')
showLoading: boolean;
@action
async createApiToken({
name,
pictureUrl,
permissions,
}: {
name: string;
pictureUrl: string | null;
permissions: string[];
}) {
const project = this.project;
return this.mutateResource({
mutation: apiTokenCreateQuery,
successMessage: FLASH_MESSAGE_API_TOKEN_ADD_SUCCESS,
errorMessage: FLASH_MESSAGE_API_TOKEN_ADD_ERROR,
variables: {
projectId: project.id,
pictureUrl,
name,
permissions,
},
});
}
@action
async revokeApiToken(apiToken: {id: string}) {
return this.mutateResource({
mutation: apiTokenRevokeQuery,
successMessage: FLASH_MESSAGE_API_TOKEN_REVOKE_SUCCESS,
errorMessage: FLASH_MESSAGE_API_TOKEN_REVOKE_ERROR,
variables: {
id: apiToken.id,
},
});
}
private async mutateResource({
mutation,
variables,
successMessage,
errorMessage,
}: {
mutation: any;
variables: any;
successMessage: string;
errorMessage: string;
}) {
const response = await this.apolloMutate.mutate({
mutation,
variables,
refetchQueries: ['ProjectApiToken'],
});
if (response.errors) {
this.flashMessages.error(this.intl.t(errorMessage));
} else {
this.flashMessages.success(this.intl.t(successMessage));
}
return response;
}
}

View File

@ -23,8 +23,9 @@ export default class APITokenRoute extends Route {
projectApiTokenQuery,
{
props: (data) => ({
accessToken: data.viewer.accessToken,
project: data.viewer.project,
accessToken: data.viewer.accessToken,
apiToken: data.viewer.project.apiTokens,
}),
options: {
fetchPolicy: 'cache-and-network',

View File

@ -3,7 +3,11 @@
{{else}}
<ProjectSettings::BackLink @project={{this.project}} />
{{#if this.project.accessToken}}
<ProjectSettings::ApiToken @projectToken={{this.project.accessToken}} @userToken={{this.accessToken}} />
{{/if}}
<ProjectSettings::ApiToken
@projectTokens={{this.project.apiTokens}}
@permissions={{this.permissions}}
@userToken={{this.accessToken}}
@onCreate={{fn this.createApiToken}}
@onRevoke={{fn this.revokeApiToken}}
/>
{{/if}}

View File

@ -0,0 +1,38 @@
import gql from 'graphql-tag';
export interface CreateApiTokenVariables {
name: string;
projectId: string;
pictureUrl: string;
permissions: string[];
}
export interface CreateApiTokenResponse {
apiToken: {
id: string;
};
errors: any;
}
export default gql`
mutation ApiTokenCreate(
$name: String!
$pictureUrl: String
$projectId: ID!
$permissions: [String!]
) {
createApiToken(
name: $name
pictureUrl: $pictureUrl
permissions: $permissions
projectId: $projectId
) {
apiToken {
id
}
errors
}
}
`;

View File

@ -8,7 +8,18 @@ export default gql`
project(id: $projectId) {
id
name
accessToken
apiTokens {
id
token
insertedAt
customPermissions
user {
id
fullname
pictureUrl
}
}
}
}
}

View File

@ -6,7 +6,6 @@ export default gql`
project(id: $projectId) {
id
name
accessToken
integrations {
id

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export interface RevokeApiTokenVariables {
id: string;
}
export default gql`
mutation ApiTokenRevoke($id: ID!) {
revokeApiToken(id: $id)
}
`;

View File

@ -3,7 +3,7 @@ import gql from 'graphql-tag';
export default gql`
query TranslateTextProject(
$projectId: ID!
$text: String
$text: String!
$sourceLanguageSlug: String!
$targetLanguageSlug: String!
) {

View File

@ -119,6 +119,7 @@ input[type="radio"] {
}
input[type="checkbox"] {
flex-shrink: 0;
vertical-align: bottom;
}
@ -138,4 +139,4 @@ select,
textarea {
margin: 0;
border: 0;
}
}

View File

@ -40,7 +40,6 @@ export const fakeProject = (params?: object) => ({
'sync',
'delete_document',
'update_document',
'show_project_access_token',
'index_project_integrations',
'create_project_integration',
'update_project_integration',