1
0
mirror of https://github.com/google/nomulus synced 2026-01-25 15:12:13 +00:00

Compare commits

..

119 Commits

Author SHA1 Message Date
gbrodman
54c5a9450d Add RegistrationBehavior.NONPREMIUM_CREATE (#2481)
When using this token (which must be tied to a particular domain), the
first year price (and only the first year price, i.e. the creation
price) will be the standard price for this TLD. Future years (i.e.
renewals) will continue at the normal premium price.
2024-06-26 20:01:32 +00:00
gbrodman
0f0097c15c Wait to load domain list until a registrar is selected (#2485)
This isn't the worst thing in the world but it does result in a bad
request to the server otherwise, and log/error spam. So, only load the
domains list if we have a registrar selected.
2024-06-25 18:39:53 +00:00
Ben McIlwain
c9437d8c72 Make registrant nullable on domains (#2477)
This is the first step in migrating to the minimum registration data set. Note
that our database model already permits null domain registrants, so this just
makes the code accept it as well. Note that I haven't changed any requirements
in EPP flows yet; a later step will be to check the migration schedule and then
not require the registrant to be present if in a suitable state.

This does potentially affect the output of WHOIS/RDAP, but that's a NOOP so long
as EPP commands and other tools continue to enforce the requirement of a
registrant.
2024-06-20 15:22:38 +00:00
Weimin Yu
19819444af Set upper bound of netty version (#2482)
A new alpha version is introduced and breaks our tests.
2024-06-17 19:43:36 +00:00
Pavlo Tkach
15df3aea44 Update Angular 17 -> 18 (#2479) 2024-06-14 23:09:34 +00:00
Weimin Yu
d000a5dff8 Use replica db for non-mutating epp flows (#2478)
* Use replica db for non-mutating epp flows

* Add a test
2024-06-13 23:18:56 +00:00
sarahcaseybot
34694b4aef Add the FeatureFlag entity (#2464)
* Add FeatureFlag entity

* Add converter

* Add loading cache

* Add more tests

* Fix NPE in cache

* small fixes
2024-06-12 16:44:08 +00:00
dependabot[bot]
7ce7b23450 Bump braces (#2476)
Bumps the npm_and_yarn group with 1 update in the /console-webapp directory: [braces](https://github.com/micromatch/braces).


Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-11 19:19:16 +00:00
Lai Jiang
a5d1469281 Upgrade to Gradle 8.8 (#2475) 2024-06-10 14:56:10 +00:00
Pavlo Tkach
a90a85afae Fix domain page "not found" layout issue (#2474) 2024-06-08 11:36:23 +00:00
Weimin Yu
6e68876a14 Use replica for whois/rdap (#2470) 2024-06-07 17:03:55 +00:00
Lai Jiang
11231703d5 Upgrade to jakarta mail (#2473) 2024-06-07 15:28:36 +00:00
gbrodman
b77a219e19 Move domain-list search+download outside of loading bar (#2457)
This means that they'll stick around even while we're loading domains
from the server.

https://b.corp.google.com/issues/343213150
2024-06-06 20:35:20 +00:00
Pavlo Tkach
bd8e6354b5 Add new registrar screen to the console (#2469) 2024-06-07 00:21:53 +00:00
Weimin Yu
361094f537 BSA check in DomainCheckFlow should check TLD (#2472)
Should not block labels if the tld is not enrolled with BSA.
2024-06-06 19:30:36 +00:00
sarahcaseybot
d53177e44c Add domain creates to the load testing client (#2458)
* Add domain creates to the load testing client

* Update contact create
2024-06-06 17:30:12 +00:00
sarahcaseybot
e73f646e1f Add FeatureFlag table to the database (#2463)
* Add FeatureFlag table to the database

* Change status to hstore
2024-06-06 17:17:11 +00:00
Lai Jiang
1a5dfb0ac2 Upgrade schemacrawler (#2471) 2024-06-06 14:51:13 +00:00
Lai Jiang
49cb1875d1 Upgrade dependencies (#2468) 2024-06-05 15:50:42 +00:00
gbrodman
61eee45ad0 Add renewalPrice fields to AllocationToken in SQL (#2462)
This is an optional field (will be required when the renewal price
behavior is SPECIFIED). This will allow us to set arbitrary renewal
prices for domains as part of one-off negotiations.

https://b.corp.google.com/issues/332928676
2024-06-03 19:50:58 +00:00
Weimin Yu
e99a18f54f Pass log trace_id to TimeLimiter task (#2466)
Code executed by TimeLimiter is in another thread. Pass on the log
trace_id if exists.
2024-06-03 19:38:17 +00:00
Pavlo Tkach
0c123e1676 Unify email notifications for console updates (#2459) 2024-05-31 19:20:56 +00:00
Lai Jiang
81b239c6b3 Add a presubmit test to catch accidental usage of javax.servlet (#2461) 2024-05-31 13:34:50 +00:00
Weimin Yu
ea8c34bf8b Fix Flyway Gradle tasks (#2460) 2024-05-30 19:23:29 +00:00
Weimin Yu
b3e67e58b5 Change billing for multi-year domain creation (#2446)
* Change billing for multi-year domain creation

From the second year on, charge the renewal price.

See b/322833077
2024-05-29 13:19:54 -04:00
gbrodman
589041b3ed Fully reset domain-list page on registrar change (#2456)
When the registrar changes we should reset the page and the total
results to 0 (since we haven't loaded them yet)

https://b.corp.google.com/issues/343193698
2024-05-29 12:54:01 -04:00
Lai Jiang
455364ff29 Remove GAE Users service API usage (#2414)
This is the last remaining GAE API that we depend on. By removing it, we are able to remove all common GAE dependencies as well.

To merge this PR, we need to create console User objects that have the same email address as the RegistrarPoc objects' login_email_address and copy over the existing registry lock hashes and salts.

We are also able to simply the code base by removing some redundant logic like AuthMethod (API is now the only supported one) and UserAuthInfo (console user is now the only supported one)

There are several behavioral changes that are worth noting:

The XsrfTokenManager now uses the console user's email address to mint and verify the token. Previously, only email addresses returned by the GAE Users service are used, whereas a blank email address will be used if the user is logged in as a console user. I believe this was an oversight that is now corrected.
The legacy console will return 401 when no user is logged in, instead of redirecting to the Users service login flow.
The logout URL in the legacy console is changed to use the IAP logout flow. It will clear the cookie and redirect the users to IAP login page (tested on QA).
The screenshot changes are mostly due to the console users lacking a display name and therefore showing the email address instead. Some changes are due to using the console user's email address as the registry lock email address, which is being fixed in Add DB column for separate rlock email address #2413 and its follow-up RPs.
2024-05-29 12:37:44 -04:00
Lai Jiang
d90bc1a3e4 Update db README (#2449) 2024-05-29 11:54:31 -04:00
Lai Jiang
0e3875c1ff Removing leading newline from GKE log messages (#2454)
GKE now displays log messages correctly. There is no need for an extra
leading newline, which now results in a useless blank line for each log
entry in Log Explorer.
2024-05-29 11:54:19 -04:00
Lai Jiang
3b565b96b7 Add the ability to add/remove console users from a Google Group (#2450)
# Conflicts:
#	config/presubmits.py
2024-05-28 17:00:37 +00:00
Pavlo Tkach
ec6c77927f Add console backend for editing registrar (#2452) 2024-05-28 00:53:32 +00:00
Lai Jiang
e88ff77ecb Harmonize http status code usage (#2451)
Given that we run servlets, it makes sense to always use the status
code contants from the servlet class.
2024-05-24 18:46:37 +00:00
sarahcaseybot
0781010b16 Create a load testing EPP client (#2415)
* Create a load testing EPP client.

This code is mostly based off of what was used for a past EPP load testing client that can be found in Google3 at https://source.corp.google.com/piper///depot/google3/experimental/users/jianglai/proxy/java/google/registry/proxy/client/

I modified the old client to be open-source friendly and use Gradle.

For now, this only performs a login and logout command, I will further expand on this in later PRs to add other EPP commands so that we can truly load test the system.

* Small changes

* Remove unnecessary build dep

* Add gradle build tasks

* Small fixes

* Add an instances setUp and cleanUp script

* More modifications to instance setup scripts

* change to ubuntu instance

* Add comment to make ssh work
2024-05-23 21:37:34 +00:00
Pavlo Tkach
ab4bac05d1 Replace RegistryTestServerMain start address with ipv6 loopback (#2448) 2024-05-21 19:00:25 +00:00
Pavlo Tkach
8e22ce7c70 Add phone and fax number to console whois endpoint (#2447) 2024-05-20 20:32:23 +00:00
Lai Jiang
d96a5547ce Store stack trace in a separate filed during logging (#2444)
For reasons unclear to me, if the stack trace is appended directly to
the message, the log entry will be lumped together with following logs
on GKE.

Also updated the GKE service account for Nomulus in the manifest so we
can use workload identity just for Nomulus, not other pods on the same
cluster.
2024-05-20 16:17:56 +00:00
Pavlo Tkach
05b43965d1 Fix console EPP password form, minor adjustments (#2445) 2024-05-17 18:44:44 +00:00
gbrodman
43000a5f80 Refactor common exception handling in ConsoleApiAction (#2443)
There are a bunch of cases where we want common exception handling and
it's annoying to have to deal with the common "set failed response and
make sure to return" a bunch of times.

This allows us to break up request methods more easily, since we can now
often throw exceptions that will break all the way back up to
ConsoleApiAction. Previously, any error handling had to exist in the
primary handler method so it could return.
2024-05-16 18:29:14 +00:00
Pavlo Tkach
a66b9ea749 Add OTE registrars to console /registrars response (#2440) 2024-05-16 14:49:32 +00:00
sarahcaseybot
36a660a8ad Remove createBillingCost columns from db schema (#2438) 2024-05-15 18:19:36 +00:00
gbrodman
d09bb4ff74 Refactor some registry lock verification code (#2434)
The user, on the front end, should not be required to provide whether or
not they're trying to verify a lock or an unlock. They should only need
the verification code. We can inspect the lock object itself (and the
domain in question) to see whether or not we're verifying a lock or an
unlock.
2024-05-14 16:56:32 +00:00
Lai Jiang
6ca3cc230f Make logging work correctly on Jetty (#2442) 2024-05-14 14:36:26 +00:00
Pavlo Tkach
6a5d8ed3b5 Allow console access for all not NONE-type global users (#2441) 2024-05-13 19:24:11 +00:00
Pavlo Tkach
53dcba1189 Update jetty's console build path (#2439) 2024-05-13 16:08:57 +00:00
Pavlo Tkach
3e77004274 Create gcp console service (#2433) 2024-05-10 21:29:58 +00:00
gbrodman
fd21fcdb84 Add a GetUserCommand (#2435)
This is fairly simple, similar to most of the other Get*Command classes
2024-05-10 21:25:52 +00:00
gbrodman
ae14e35df7 Change a few wording bits in the console (#2436)
These probably aren't perfect but they seemed to make sense given the
old console. Nothing major.
2024-05-10 18:27:55 +00:00
Lai Jiang
94dc9fd0d5 Update GcpJsonFormatter (#2437)
Use the correct JSON field to store the source location info so it can
be parsed by Stackdriver.
2024-05-10 16:47:52 +00:00
gbrodman
7b34659a8f Add registryLockEmailAddress field to User object (#2418)
We've added the field in the database in a previous PR. This is only
used in the old console for now because the new console does not have
registry lock functionality yet
2024-05-09 21:42:45 +00:00
sarahcaseybot
808432e709 Remove the createBillingCost field from Tld (#2425)
* Remove the createBillingCost field from Tld

* fix spacing

* Change field name of map

* Rename getter

* Fix formatting

* Fix todo

* unchange column name
2024-05-08 18:14:03 +00:00
Lai Jiang
73d3b76a89 Remove more usage of AutoValue (#2432)
This PR also removes `SerializedForm` used to serialize
`PendingDeposit`, as it is now a simple record.
2024-05-08 00:50:01 +00:00
Weimin Yu
ca072b4861 Add log traces to Nomulus service on GKE (#2427)
* Add log traces to Nomulus service on GKE

Add request-scope log traces to Nomulus on GKE which, unlike
AppEngine and Cloud Run etc, does not generate traces for hosted
applications. This change only affects the GKE image. It does not affect
the AppEngine services.

Log traces are added to Nomulus-generated logs in request-processing
threads. Forked threads are not covered yet. The single relevant use
case (TimeLimiter) will be addressed in a followup PR.

The main change is in the logging configuration:

*  Use gcp-cloud-logging's LoggingHandler

*  Add gcp-cloud-logging's TraceLoggingEnhancer to the handler.

*  Set a thread-local trace id through the TraceLoggingEnhancer in
   ServletBase on request's entry and clear it on completion.

Also removed an unused class (`RequestLogId`).

* CR

* CR
2024-05-07 19:15:46 +00:00
Pavlo Tkach
54c896cbb9 Add console epp password integration (#2426) 2024-05-06 18:32:54 +00:00
Pavlo Tkach
2c7bf2cfdb Update cloudbuild-release.yaml with nokeycheck option (#2431) 2024-05-06 18:01:31 +00:00
gbrodman
49d2e34e12 Add a separate RegistryLock action for the console (#2411)
This handles both GET and POST requests. For POST requests it doesn't
actually change anything about the domains because we will need to add a
verification action (this will be done in a future PR).
2024-05-03 22:37:22 +00:00
Weimin Yu
5511b41f93 Avoid contention over the RefreshDnsRequest table (#2428)
* Avoid contention over the RefreshDnsRequest table

This table can be small at times, when PSQL may use table scan in
queries by keys. At the SERIALIZABLE isolation level, updates to
unrelated rows may conflict due to this `optimization`.

Lower the isolation level to repeatable read.

* Code review
2024-05-03 20:31:54 +00:00
gbrodman
147cdff555 Add registry lock email address col to UserUpdateHistory (#2424) 2024-05-02 15:51:48 +00:00
Ben McIlwain
4b6ade0b14 Bring codebase up to more recent Java standards (#2422)
This includes using the new switch format (though IntelliJ does not yet
understand patterns including default so those aren't used), multiline strings,
replacing some unnecessary type declarations with <>, converting some classes to
records, replacing some Guava predicates with native Java code, and some other
miscellaneous Code Inspection fixes.
2024-05-01 20:48:38 +00:00
Pavlo Tkach
570618705e Allow console access for FTE globar role (#2419) 2024-05-01 16:19:29 +00:00
sarahcaseybot
e791608098 Add more indexes to speed up deleteProberDataAction (#2423)
This adds an index on transfer_billing_cancellation_id to Domain and superordinate_domain to Host. When tested on crash with the action limited to only delete 10,000 domains, before these indexes were added the action took about 2 hours to delete 10,000 domains. Once these indexes were added, the action was able to delete the 10,000 domains in a little under 2 minutes.
2024-05-01 15:44:08 +00:00
gbrodman
03b358726a Add Java classes for console history objects (#2350)
This also creates base classes for the objects contained within the
history classes, e.g. RegistrarBase. This is the same way that objects
stored in the HistoryEntry subclasses have base classes, e.g.
DomainBase.
2024-04-30 20:42:40 +00:00
gbrodman
d121f8f547 Generate fake XSRF token in FakeConsoleApiParams for tests (#2421) 2024-04-30 17:47:53 +00:00
gbrodman
b27218d799 Fix a couple Checkstyle warnings (#2420) 2024-04-29 20:08:53 +00:00
Pavlo Tkach
e78ce42dd5 Add console DUM download (#2402)
* Add console DUM download

* Add console DUM download
2024-04-26 15:56:50 +00:00
Ben McIlwain
55fade497d Convert a bunch more @AutoValues to records (#2412) 2024-04-25 16:59:31 +00:00
gbrodman
e7501b621a Add DB column for separate rlock email address (#2413)
We cannot rely on the user checking their login email, so we'll want to
send the emails to the other address if configured. This is already the
case in RegistrarPoc.
2024-04-25 15:38:57 +00:00
Weimin Yu
9c443bede1 Fix conflicts between locks (#2407)
Use REPEATABLE READ for lock acquire/release operation to avoid conlicts
between locks.

Postgresql uses table scan on small tables, causing false sharing at
the SERIALIZABLE isolation level.

See b/333537928 for details.
2024-04-24 18:51:18 +00:00
Lai Jiang
6d0a746b76 Bind console users to the appropriate IAP roles upon creation (#2403)
Console users need IAP to inject the necessary OIDC tokens into their
request headers and therefore need to be bound to appropriate roles. Note
that in environments managed by latchkey, the bindings will need to be
present in latchkey config files as well, otherwise the changes made by
the nomulus tool will be reverted.

TESTED=ran the nomulus command against alpha and verified that the
bindings are created/removed upon console user creation/deletion.
2024-04-24 15:03:43 +00:00
Pavlo Tkach
0765e7b209 Console deps update (#2409)
* Update angular/core to 17.3.5

* Update angular/material 17.3.5

* Update angular/cli 17.3.5

* Update angular-eslint 17.3.0

* Disable cli cache

* General console deps update
2024-04-23 19:38:32 +00:00
sarahcaseybot
f729802094 Make createBillingCostTransitions not null (#2405)
* Make createBillingCostTransitions not null

* Set up createBillingCost field to be removed form config files

* Add clarifying comment
2024-04-23 18:22:45 +00:00
Ben McIlwain
e809e967a3 Convert more @AutoValues to records, particularly in custom flow classes (#2408) 2024-04-22 20:25:33 +00:00
Pavlo Tkach
4de2bd5901 Add console backend for EPP password change (#2396) 2024-04-20 10:44:26 +00:00
sarahcaseybot
b5629ff16f Run deleteProberData cron job daily (#2406)
* Run deleteProberData cron job daily

* Sign the commits

* try signing again
2024-04-19 19:32:14 +00:00
Ben McIlwain
91615aef54 Handle bad header names in registrar sheet syncing action (#2404)
The existing behavior was to ignore bad header names, in a way that was
counter-intuitive as a user of the Google Sheet. If a header name was bad (which
could just be someone accidentally changing it not realizing it needs to
correspond exactly to the name of the field on the Java object), then all of the
data in that column was just silently left as-is and never updated. This led to
gradually worsening sync and offset shift errors over time.

Now, it will write out an error message into every single cell in the bad
column, so it's clear that the column name is wrong and does not correspond to any
actual data in the DB.

BUG=http://b/332336068
2024-04-19 17:59:58 +00:00
Ben McIlwain
fa6898167b Convert more @AutoValues to Java records (#2378) 2024-04-17 19:30:23 +00:00
Lai Jiang
903b7979de Upgrade to jline 3 (#2400)
jline 3 contains API breaking changes, necessitating changes in
ShellCommand.
2024-04-12 19:57:02 +00:00
Weimin Yu
8721085d14 Fix BSA validation (#2401)
Unblocked reserved names wrongly reported as missing unblockable domain.
2024-04-12 19:54:59 +00:00
Lai Jiang
e434528cd3 Add nomulus deployment and service manifests (#2389) 2024-04-11 19:01:09 +00:00
Pavlo Tkach
9ca54e4364 Add UI for EPP Password update (#2393) 2024-04-10 22:29:52 +00:00
Weimin Yu
a16794e2af Run BSA Validate without lock (#2399)
As a read-only action that tolerates staleness, locking is unnecessary.
This should help with the lock contention we are observing.

Also reduces the number of VM instances provisioned for BSA and increase
the idle timeout. This should reduce invocation delay. Longer delay may
cause AppEngine to return `Timeout` status to Cloud Scheduler even
though the cron job succeeds.
2024-04-10 19:58:24 +00:00
Lai Jiang
496a781572 Upgrade jcommander (#2398) 2024-04-10 17:34:11 +00:00
Ben McIlwain
2df583df1a Statically import Truth.assertThat() in tests (#2395)
This also involved breaking out an improperly done assertThat() helper overload
method for JsonObjects into a proper Subject that doesn't further overload
assertThat().
2024-04-09 16:27:26 +00:00
sarahcaseybot
4f1ca920a7 Use the createBillingCostTransitions map to get the create cost for a domain (#2390)
* Use the createBillingCostTransitions map to get the create cost for a domain

* Add comment

* Add some TODOs

* use streams to check currency unit
2024-04-05 21:27:55 +00:00
Weimin Yu
96e33f5b4f Check for missing BSA unblockable domains (#2394)
* Check for missing BSA unblockable domains

All unblockable domains created before the last refresh run should be
reported as unblockable (registered).

All reserved domains that are not registered should be reported as
unblockable (reserved). Note that transient errors may be reported for
newly added reserved domains since we do not maintain update time for
when a reserved label is associated with a TLD. However, this scenario
is extremely rare in operations.

* Addressing review
2024-04-03 00:44:05 +00:00
sarahcaseybot
dff2d90325 Add batching to DeleteProberDataAction (#2322)
* Add batching to DeleteProberDataAction

* Only get time once

* Add separate query for dry run

* Update querries to actually properly delete all the data

* Fix merge conflicts

* Add test for foreign key constraints

* Make transaction repeatable read

* Make queries to subtables native

* Add native query for GracePeriodHistory

* Kill job after 20 hours

* remove extra time check from read query
2024-03-29 20:51:19 +00:00
sarahcaseybot
fa344776a1 Drop the should_publish column from ReservedList (#2392) 2024-03-29 16:21:11 +00:00
Pavlo Tkach
eb164809de Add console favicon (#2391) 2024-03-27 20:32:01 +00:00
Pavlo Tkach
4ddbeb6d06 Add console registrar field focus handler, split whois address field (#2388) 2024-03-27 18:55:57 +00:00
dependabot[bot]
fa53391395 Bump express from 4.19.1 to 4.19.2 in /console-webapp (#2387)
Bumps [express](https://github.com/expressjs/express) from 4.19.1 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.19.1...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-26 17:28:43 +00:00
sarahcaseybot
856e70cf8e Add indexes on domainRepoId to DomainHistoryHost and PollMessage (#2380)
* Add index for domainRepoId to PollMessage and DomainHistoryHost

* Add flyway fix for Concurrent

* fix gradle.properties

* Modify lockfiles

* Update the release tool and add IF NOT EXISTS

* Test removing transactional lock from deploy script

* Add transactional lock flag to actual flyway commands in script

* Remove flag from info command

* Add configuration for integration test
2024-03-26 16:44:14 +00:00
Lai Jiang
2037611931 Upgrade to Gradle 8.7 (#2386) 2024-03-25 23:57:40 +00:00
Lai Jiang
af1f6e5708 Compile to Java 21 bytecode (#2374)
We have been running in Java 21 runtime for a couple of weeks and every
works as expected.
2024-03-25 13:50:39 +00:00
Weimin Yu
0df8372407 Change BSA job status notifications (#2385)
Add error notifications for BsaDownload.

Stop sending success notifications.
2024-03-22 19:27:25 +00:00
Pavlo Tkach
59f4129ee0 Restyle registrar console based on the new design proposal (#2336) 2024-03-21 22:05:09 +00:00
Weimin Yu
de3af34b66 Verify unblockables are truly unblockable (#2381)
* Verify unblockables are truly unblockable

Unblockable domains may become blockable due to deregistration or
removal from the reserved list. The BSA refresh job is responsible
for removing them from the database. This PR verifies that the refreshes
are correct.

Note that recent changes since last refresh are not reflected in the
result, and inconsistency due to recent deregistrations are ignored.
Changes in reserved status or IDN validity are not timestamped,
therefore we cannot ignore recent inconsistencies. However, these
changes are rare.

* Addressing code review

* Addressing code review
2024-03-20 18:52:17 +00:00
Weimin Yu
5c62dc78ba Fix nom_build command (#2383) 2024-03-20 13:12:59 +00:00
Lai Jiang
3fdecde6e9 Add a method to test if Nomulus is running on Jetty (#2382) 2024-03-19 15:43:52 +00:00
Lai Jiang
e7bf74d91d Upgrade to Jakarta EE 10 Servlet (#2362)
Upgrade to using Jakarta EE 10 from Java EE 8 by mostly following the upgrade instructions. Only the servlet package is upgrade. Other Jakarta EE components (like the persistence package that Hibernate depends on) need to be upgraded separately.

TESTED=deployed and successfully communicated with the pubapi endpoint for web WHOIS.

Note that this currently requires packaing the App Engine runtime per instructions here due to GoogleCloudPlatform/appengine-java-standard#98. This PR will only be merged until the fix is deployed to production (https://rapid.corp.google.com/#/release/serverless_runtimes_run_java/java21_20240310_21_0).
2024-03-18 18:00:55 +00:00
Lai Jiang
ff211fb4f9 Remove buildSrc (#2379)
We don't use the upload results feature (kokoro picks the results
artifacts directly and uploads them).

Keeping it around is a maintenance burden.

Also fixed a deprecation warning.
2024-03-18 14:29:51 +00:00
dependabot[bot]
3a7c53d895 Bump follow-redirects from 1.15.4 to 1.15.6 in /docs/console-endpoints (#2375)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-15 19:06:26 -04:00
Weimin Yu
f5b279a288 Add IDN check to BSA validate (#2370)
Labels that are not in any supported IDN are not added to the database.

Remove such labels from those loaded from the block list files before
comparing with DB.
2024-03-15 19:06:12 -04:00
Lai Jiang
c68583f666 Remove java.util.Date (#2373)
There is one remaining instance in JpaTransactionManagerImpl that cannot
be removed because DetachingTypedQuery is implementing TypedQuery, which has
a method that expectred java.util.Date.
2024-03-15 19:06:00 -04:00
Pavlo Tkach
6d2eb2e140 Update build.gradle (#2377)
Console tests fail for the files that are affected by redesign. There's no point in fixing it here. I will reenable the task after the console redesign PR is merged
2024-03-15 17:23:02 +00:00
dependabot[bot]
00a2022292 Bump follow-redirects from 1.15.5 to 1.15.6 in /console-webapp (#2376)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-15 14:21:46 +00:00
Lai Jiang
de372c4d47 Replace javax.inject with jakarta.inject (#2372)
Note that Dagger currently doesn't work with the Jakarta namespace and
we have to cap the jakarta inject package version below 2.0 so that it
sill provides classes in the old namespace.
2024-03-15 03:26:53 +00:00
sarahcaseybot
5f9c7de516 Drop should publish field from ReservedList entity (#2369)
* Remove the shouldPublish field from the reservedLIst entity

* Add generated schema file
2024-03-14 22:04:18 +00:00
Lai Jiang
6e57d93507 Upgrade a few more dependencies (#2371)
* jaxb and gmail

* admin-directory

* Upgrade a few more dependencies
2024-03-14 17:37:54 +00:00
Lai Jiang
b9cfa65546 Refactor some code related to the transaction manager (#2366)
Removed the deprecation mark as it is natural to expose methods related
to a transaction like getting the entity manager or checking if one is
in a transaction through the transaction manager interface.
2024-03-14 14:37:44 +00:00
Weimin Yu
9af006836c Add email notification of BSA job status (#2368) 2024-03-13 19:14:02 +00:00
Lai Jiang
cd95be4776 Upgrade a few pinned dependencies (#2359) 2024-03-13 11:52:34 +00:00
Lai Jiang
bdc9a1fd1d Fix nomulus tool when the environment is localhost (#2365)
Also only caches/resets the original TM when in unit tests (TBT I'm not so sure
that even this is necessary as we don't seem to call the tool from tests
that often. There is only ShellCommandTest that calls the run() function
in RegistryCli and we could just put these tests in fragileTest and make
them run sequentially and fork every time to get around issue with
inference).

The issue with caching is that it tries to first create the to-be-cached
TM, and when the environment given is prod/sandbox/... It will try to
retrieve SQL credentials from prod/sandbox/... secret manager. This
works fine locally as we all have access to prod/sandbox/..., but fails
in Cloud Build jobs such as sync-db-objects where it provides it own
credential that has direct SQL access, but not access to
prod/sandbox/... secret manager.

TESTED=ran `./gradlew devTool --args="-e localhost generate_sql_er_diagram -o ../db/src/main/resources/sql/er_diagram"`
2024-03-13 04:49:07 +00:00
Lai Jiang
d0b036227a Add a GitHub action to block merging based on the labels (#2367) 2024-03-13 02:47:37 +00:00
gbrodman
0f02858965 Rename console update mod time to history_modification_time (#2363) 2024-03-12 20:38:15 +00:00
Lai Jiang
6acb14c60d Add a test to ensure all actions are routable by the RegistryServlet (#2361) 2024-03-12 17:18:44 +00:00
Lai Jiang
e881f254f8 Add a GitHub Action (#2360)
* Add a GitHub Action workflow

This allows us to create Gradle depedency graphs for Dependabot analysis (as the ones we already get for Javascript dependencies).

* Update Java version

* Add build scan

* codeql 3

* run with gradle

* exclude jIFC

* build scan

* Finalize
2024-03-11 18:55:13 +00:00
Lai Jiang
1fb27fcf8e Make nomulus work locally (#2349)
Chose the default transaction manager based on RegistryEnvironment. This
makes it possible to run nomulus on Jetty locally. Tested with the
following:

```bash
./gradle :jetty:run -Penvironment=alpha
curl http://localhost:8080/beta.app
```

The docker image is also updated to take an argument that specifies the
environment. It runs locally as well but the container doesn't get
access to locally stored credentials, so it fails to initialize the
transaction manager.
2024-03-11 16:05:44 +00:00
Weimin Yu
34a8a94083 Add BSA validation job (#2356)
* Add BSA validation job

Add the BsaValidateAction class with a first check (for inconsistency
between downloaded and persisted labels).

* Addressing comments

* Addressing reviews
2024-03-08 22:08:09 +00:00
Lai Jiang
779dc36858 Remove some dead gradle code (#2358)
runtime is not actually a predefined confiugration and it doesn't affect
the runtime classpath in anyway.

See: https://docs.gradle.org/current/userguide/java_plugin.html#tab:configurations
2024-03-08 15:12:11 +00:00
Lai Jiang
40174b825a Change ICANN upload cursor time (#2346)
The staging job runs at 9AM on the 2nd day of each month, we should set
the cursor to be after that time, otherwise we attempt to upload reports
on the 1st day of each month before they are ready, causing an error
email to be sent to us.
2024-03-07 15:52:14 +00:00
995 changed files with 28204 additions and 21769 deletions

View File

@@ -7,7 +7,7 @@ on:
# The branches below must be a subset of the branches above
branches: [ 'master' ]
schedule:
- cron: '24 4 * * 2'
- cron: '24 4 * * *'
jobs:
analyze:
@@ -27,17 +27,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set Java version
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,11 +47,20 @@ jobs:
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
queries: security-and-quality
# Build with Gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
build-scan-publish: true
build-scan-terms-of-service-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-service-agree: "yes"
- name: Execute Gradle build
run: ./gradlew build -x test -x jIFC
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
#- name: Autobuild
# uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -64,6 +73,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -0,0 +1,25 @@
name: Dependency Submission
on:
push:
branches: [ 'master' ]
schedule:
- cron: '24 3 * * *'
permissions:
contents: write
jobs:
dependency-submission:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Set Java version
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v3

23
.github/workflows/do-not-merge.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: "Check labels"
on:
pull_request:
branches: ["master"]
types:
- opened
- synchronize
- labeled
- unlabeled
merge_group:
branches: ["master"]
types: [checks_requested]
jobs:
fail-by-label:
runs-on: ubuntu-latest
steps:
- name: Fail if PR is labeled as "do not merge"
if: contains(github.event.pull_request.labels.*.name, 'do not merge')
run: |
echo "This PR is labeled as do not merge!"
exit 1

View File

@@ -47,10 +47,6 @@ war {
if (project.path == ":services:default") {
war {
from("${rootDir}/console-webapp/dist/console-webapp") {
include "**/*"
into("console")
}
from("${coreResourcesDir}/google/registry/ui") {
include "registrar_bin.js"
if (environment != "production") {
@@ -103,10 +99,11 @@ explodeWar.doLast {
file("${it.explodedAppDirectory}/WEB-INF/lib/tools.jar").setWritable(true)
}
appengineDeployAll.mustRunAfter ':console-webapp:deploy'
appengineDeployAll.finalizedBy ':deployCloudSchedulerAndQueue'
rootProject.deploy.dependsOn appengineDeployAll
rootProject.stage.dependsOn appengineStage
tasks['war'].dependsOn ':console-webapp:buildConsoleWebappProd'
tasks['war'].dependsOn ':core:compileProdJS'
tasks['war'].dependsOn ':core:processResources'
tasks['war'].dependsOn ':core:jar'

View File

@@ -36,8 +36,7 @@ buildscript {
}
plugins {
// Java static analysis plugins. Keep versions consistent with
// ./buildSrc/build.gradle.kts
// Java static analysis plugins.
// Re-enable when compatible with Gradle 8
// id 'nebula.lint' version '16.0.2'
@@ -68,44 +67,12 @@ wrapper {
distributionType = Wrapper.DistributionType.ALL
}
apply plugin: google.registry.gradle.plugin.ReportUploaderPlugin
reportUploader {
// Set the location where we want to upload the build results.
// e.g. -P uploaderDestination=gcs://domain-registry-alpha-build-result-test
//
// If not set - the upload will be skipped
destination = uploaderDestination
// The location of the file containing the OAuth2 Google Cloud credentials.
//
// The file can contain a Service Account key file in JSON format from the
// Google Developers Console or a stored user credential using the format
// supported by the Cloud SDK.
//
// If no file is given - the default credentials are used.
credentialsFile = uploaderCredentialsFile
// If set to 'yes', each file will be uploaded to GCS in a separate thread.
// This is MUCH faster.
multithreadedUpload = uploaderMultithreadedUpload
}
apply from: 'dependencies.gradle'
apply from: 'dependency_lic.gradle'
apply from: 'utils.gradle'
// Custom task to run checkLicense in buildSrc, which is not triggered
// by root project tasks. A shell task is used because buildSrc tasks
// cannot be referenced in the same way as tasks from a regular included
// build.
task checkBuildSrcLicense(type:Exec) {
workingDir "${rootDir}/buildSrc"
commandLine '../gradlew', 'checkLicense'
}
tasks.checkLicense.dependsOn(tasks.checkBuildSrcLicense)
tasks.build.dependsOn(tasks.checkLicense)
// Provide defaults for all of the project properties.
@@ -360,17 +327,6 @@ subprojects {
}
}
// 'listenablefuture' is folded into guava since v32. This block is required
// until all transitive dependencies have upgraded past guava v32.
// TODO(periodically): remove this block and see if build succeeds.
configurations.all {
resolutionStrategy
.capabilitiesResolution
.withCapability("com.google.guava:listenablefuture") {
select("com.google.guava:guava:0")
}
}
// Set up all of the deployment projects.
if (services.contains(project.path)) {
@@ -386,8 +342,8 @@ subprojects {
// search for `flex-template-base-image` and update the parameter value.
// There are at least two instances, one in core/build.gradle, one in
// release/stage_beam_pipeline.sh
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
project.tasks.test.dependsOn runPresubmits
@@ -463,9 +419,6 @@ if (verboseTestOutput.toBoolean()) {
}
task checkDependenciesDotGradle {
def buildSrcDepsFile = File.createTempFile('buildSrc', 'deps')
buildSrcDepsFile.deleteOnExit()
dependsOn createGetBuildSrcDirectDepsTask(buildSrcDepsFile)
doLast {
Set<String> depsInUse = []
@@ -478,9 +431,7 @@ task checkDependenciesDotGradle {
}
}
}
if (buildSrcDepsFile.exists()) {
depsInUse.addAll(buildSrcDepsFile.readLines())
}
def unusedDeps =
rootProject.dependencyMap.keySet()
.findAll { !depsInUse.contains(it) }
@@ -497,17 +448,6 @@ task checkDependenciesDotGradle {
}
tasks.build.dependsOn(tasks.checkDependenciesDotGradle)
def createGetBuildSrcDirectDepsTask(outputFileName) {
return tasks
.create(
"getBuildSrcDeps_${java.util.UUID.randomUUID()}".toString(),
Exec) {
workingDir "${rootDir}/buildSrc"
commandLine '../gradlew', 'exportDependencies',
"-PdependencyExportFile=${outputFileName}"
}
}
rootProject.ext {
invokeJavaDiffFormatScript = { action ->
def javaHome = project.findProperty('org.gradle.java.home')
@@ -518,12 +458,8 @@ rootProject.ext {
javaBin = ext.execInBash("which java", rootDir)
}
println("Running the formatting tool with $javaBin")
def scriptDir = rootDir.path.endsWith('buildSrc')
? "${rootDir}/../java-format"
: "${rootDir}/java-format"
def workingDir = rootDir.path.endsWith('buildSrc')
? "${rootDir}/.."
: rootDir
def scriptDir = "${rootDir}/java-format"
def workingDir = rootDir
def formatDiffScript = "${scriptDir}/google-java-format-git-diff.sh"
def pythonExe = getPythonExecutable()
@@ -583,7 +519,9 @@ task javadoc(type: Javadoc) {
destinationDir = file("${buildDir}/docs/javadoc")
options.encoding = "UTF-8"
// In a lot of places we don't write @return so suppress warnings about that.
options.addBooleanOption('Xdoclint:all,-missing', true)
// We don't report HTML lint errors because XJB-generated POJO files have
// incorrect tags (like dangling </p> without the corresponding open tag.
options.addBooleanOption('Xdoclint:all,-missing,-html', true)
options.addBooleanOption("-allow-script-in-comments",true)
options.tags = ["type:a:Generic Type",
"error:a:Expected Error",
@@ -601,7 +539,6 @@ task coreDev {
dependsOn 'javadoc'
dependsOn 'checkDependenciesDotGradle'
dependsOn 'checkLicense'
dependsOn ':console-webapp:runConsoleWebappUnitTests'
dependsOn ':core:check'
dependsOn 'assemble'
}

View File

@@ -1,129 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import java.io.PrintStream;
val enableDependencyLocking: String by project
val allowInsecureProtocol: String by project
val allowInsecure = allowInsecureProtocol
buildscript {
// We need to do this again within "buildscript" because setting it in the
// main script doesn't affect build dependencies.
val enableDependencyLocking: String by project
if (enableDependencyLocking.toBoolean()) {
// Lock application dependencies.
dependencyLocking {
lockAllConfigurations()
}
}
}
plugins {
// Java static analysis plugins. Keep versions consistent with ../build.gradle
// We don't anticipate enabling the Gradle lint plugin because they will not support Kotlin
// See https://github.com/nebula-plugins/gradle-lint-plugin/issues/166
// id 'nebula.lint' version '16.0.2'
id("net.ltgt.errorprone") version "3.1.0"
checkstyle
id("com.diffplug.spotless") version "6.20.0"
}
checkstyle {
configDirectory.set(file("../config/checkstyle"))
}
println("enableDependencyLocking is $enableDependencyLocking")
if (enableDependencyLocking.toBoolean()) {
// Lock application dependencies.
dependencyLocking {
lockAllConfigurations()
}
}
repositories {
val mavenUrl = (project.ext.properties.get("mavenUrl") ?: "") as String
if (mavenUrl.isEmpty()) {
println("Java dependencies: Using Maven central...")
mavenCentral()
google()
} else {
maven {
println("Java dependencies: Using repo ${mavenUrl}...")
url = uri(mavenUrl)
isAllowInsecureProtocol = allowInsecureProtocol == "true"
}
}
}
apply(from = "../dependencies.gradle")
apply(from = "../dependency_lic.gradle")
apply(from = "../java_common.gradle")
// 'listenablefuture' is folded into guava since v32. This block is required
// until all transitive dependencies have upgraded past guava v32.
// TODO(periodically): remove this block and see if build succeeds.
configurations.all {
resolutionStrategy
.capabilitiesResolution
.withCapability("com.google.guava:listenablefuture") {
select("com.google.guava:guava:0")
}
}
project.the<SourceSetContainer>()["main"].java {
srcDir("${project.buildDir}/generated/source/apt/main")
}
dependencies {
val deps = project.ext["dependencyMap"] as Map<*, *>
val implementation by configurations
val testImplementation by configurations
val annotationProcessor by configurations
implementation(deps["com.google.auth:google-auth-library-credentials"]!!)
implementation(deps["com.google.auth:google-auth-library-oauth2-http"]!!)
implementation(deps["com.google.auto.value:auto-value-annotations"]!!)
// implementation(deps["com.google.common.html.types:types"]!!)
implementation(deps["com.google.cloud:google-cloud-core"]!!)
implementation(deps["com.google.cloud:google-cloud-storage"]!!)
implementation(deps["com.google.guava:guava"]!!)
implementation(deps["com.google.protobuf:protobuf-java"]!!)
implementation(deps["com.google.template:soy"]!!)
implementation(deps["org.apache.commons:commons-text"]!!)
annotationProcessor(deps["com.google.auto.value:auto-value"]!!)
testImplementation(deps["com.google.truth:truth"]!!)
testImplementation(deps["org.junit.jupiter:junit-jupiter-api"]!!)
testImplementation(deps["org.junit.jupiter:junit-jupiter-engine"]!!)
testImplementation(deps["org.mockito:mockito-core"]!!)
}
gradle.projectsEvaluated {
tasks.withType<JavaCompile> {
options.compilerArgs.add("-Xlint:unchecked")
}
}
tasks.register("exportDependencies") {
doLast {
project.configurations.forEach {
println("dependency is $it")
// it.dependencies.findAll {
// it.group != null
// }.each {
// output.println("${it.group}:${it.name}")
// }
}
}
}

View File

@@ -1,29 +0,0 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.diffplug.durian:durian-collect:1.2.0=classpath
com.diffplug.durian:durian-core:1.2.0=classpath
com.diffplug.durian:durian-io:1.2.0=classpath
com.diffplug.durian:durian-swt.os:4.2.0=classpath
com.diffplug.spotless:com.diffplug.spotless.gradle.plugin:6.20.0=classpath
com.diffplug.spotless:spotless-lib-extra:2.40.0=classpath
com.diffplug.spotless:spotless-lib:2.40.0=classpath
com.diffplug.spotless:spotless-plugin-gradle:6.20.0=classpath
com.googlecode.concurrent-trees:concurrent-trees:2.6.1=classpath
com.googlecode.javaewah:JavaEWAH:1.2.3=classpath
com.squareup.okhttp3:okhttp:4.10.0=classpath
com.squareup.okio:okio-jvm:3.0.0=classpath
com.squareup.okio:okio:3.0.0=classpath
dev.equo.ide:solstice:1.3.1=classpath
net.ltgt.errorprone:net.ltgt.errorprone.gradle.plugin:3.1.0=classpath
net.ltgt.gradle:gradle-errorprone-plugin:3.1.0=classpath
org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r=classpath
org.eclipse.platform:org.eclipse.osgi:3.18.300=classpath
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.20=classpath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=classpath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=classpath
org.jetbrains.kotlin:kotlin-stdlib:1.9.20=classpath
org.jetbrains:annotations:13.0=classpath
org.slf4j:slf4j-api:1.7.36=classpath
org.tukaani:xz:1.9=classpath
empty=

View File

@@ -1,108 +0,0 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
aopalliance:aopalliance:1.0=annotationProcessor,buildScriptClasspath,compileClasspath
args4j:args4j:2.0.23=buildScriptClasspath,compileClasspath
com.fasterxml.jackson.core:jackson-core:2.16.1=buildScriptClasspath,compileClasspath
com.fasterxml.jackson:jackson-bom:2.16.1=buildScriptClasspath,compileClasspath
com.github.ben-manes.caffeine:caffeine:3.0.5=annotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor
com.google.android:annotations:4.1.1.4=buildScriptClasspath
com.google.api-client:google-api-client:2.3.0=buildScriptClasspath,compileClasspath
com.google.api.grpc:gapic-google-cloud-storage-v2:2.34.0-alpha=buildScriptClasspath,compileClasspath
com.google.api.grpc:grpc-google-cloud-storage-v2:2.34.0-alpha=buildScriptClasspath,compileClasspath
com.google.api.grpc:proto-google-cloud-storage-v2:2.34.0-alpha=buildScriptClasspath,compileClasspath
com.google.api.grpc:proto-google-common-protos:2.34.0=buildScriptClasspath,compileClasspath
com.google.api.grpc:proto-google-iam-v1:1.29.0=buildScriptClasspath,compileClasspath
com.google.api:api-common:2.26.0=buildScriptClasspath,compileClasspath
com.google.api:gax-grpc:2.43.0=buildScriptClasspath,compileClasspath
com.google.api:gax-httpjson:2.43.0=buildScriptClasspath,compileClasspath
com.google.api:gax:2.43.0=buildScriptClasspath,compileClasspath
com.google.apis:google-api-services-storage:v1-rev20240209-2.0.0=buildScriptClasspath,compileClasspath
com.google.auth:google-auth-library-credentials:1.23.0=buildScriptClasspath,compileClasspath
com.google.auth:google-auth-library-oauth2-http:1.23.0=buildScriptClasspath,compileClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor
com.google.auto.value:auto-value-annotations:1.10.4=buildScriptClasspath,compileClasspath
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor
com.google.auto.value:auto-value:1.10.4=annotationProcessor
com.google.auto:auto-common:1.2.1=annotationProcessor
com.google.cloud:google-cloud-core-grpc:2.33.0=buildScriptClasspath,compileClasspath
com.google.cloud:google-cloud-core-http:2.33.0=buildScriptClasspath,compileClasspath
com.google.cloud:google-cloud-core:2.33.0=buildScriptClasspath,compileClasspath
com.google.cloud:google-cloud-storage:2.34.0=buildScriptClasspath,compileClasspath
com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,buildScriptClasspath,compileClasspath
com.google.code.gson:gson:2.10.1=buildScriptClasspath,compileClasspath
com.google.common.html.types:types:1.0.6=buildScriptClasspath,compileClasspath
com.google.errorprone:error_prone_annotation:2.23.0=annotationProcessor
com.google.errorprone:error_prone_annotations:2.23.0=annotationProcessor
com.google.errorprone:error_prone_annotations:2.24.1=buildScriptClasspath,compileClasspath
com.google.errorprone:error_prone_check_api:2.23.0=annotationProcessor
com.google.errorprone:error_prone_core:2.23.0=annotationProcessor
com.google.errorprone:error_prone_type_annotations:2.23.0=annotationProcessor
com.google.escapevelocity:escapevelocity:0.9.1=buildScriptClasspath,compileClasspath
com.google.guava:failureaccess:1.0.1=annotationProcessor
com.google.guava:failureaccess:1.0.2=buildScriptClasspath,compileClasspath
com.google.guava:guava-parent:32.1.1-jre=annotationProcessor
com.google.guava:guava:32.1.1-jre=annotationProcessor
com.google.guava:guava:33.0.0-jre=buildScriptClasspath,compileClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=buildScriptClasspath,compileClasspath
com.google.http-client:google-http-client-apache-v2:1.44.1=buildScriptClasspath,compileClasspath
com.google.http-client:google-http-client-appengine:1.44.1=buildScriptClasspath,compileClasspath
com.google.http-client:google-http-client-gson:1.44.1=buildScriptClasspath,compileClasspath
com.google.http-client:google-http-client-jackson2:1.44.1=buildScriptClasspath,compileClasspath
com.google.http-client:google-http-client:1.44.1=buildScriptClasspath,compileClasspath
com.google.inject.extensions:guice-multibindings:4.1.0=buildScriptClasspath,compileClasspath
com.google.inject:guice:4.1.0=buildScriptClasspath,compileClasspath
com.google.inject:guice:5.1.0=annotationProcessor
com.google.j2objc:j2objc-annotations:2.8=buildScriptClasspath,compileClasspath
com.google.jsinterop:jsinterop-annotations:1.0.1=buildScriptClasspath,compileClasspath
com.google.oauth-client:google-oauth-client:1.35.0=buildScriptClasspath,compileClasspath
com.google.protobuf:protobuf-java-util:3.25.2=buildScriptClasspath,compileClasspath
com.google.protobuf:protobuf-java:3.19.6=annotationProcessor
com.google.protobuf:protobuf-java:3.25.2=buildScriptClasspath,compileClasspath
com.google.re2j:re2j:1.7=buildScriptClasspath
com.google.template:soy:2021-02-01=buildScriptClasspath,compileClasspath
com.ibm.icu:icu4j:57.1=buildScriptClasspath,compileClasspath
commons-codec:commons-codec:1.16.1=buildScriptClasspath,compileClasspath
io.github.eisop:dataflow-errorprone:3.34.0-eisop1=annotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor
io.grpc:grpc-alts:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-api:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-auth:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-context:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-core:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-googleapis:1.61.1=buildScriptClasspath
io.grpc:grpc-grpclb:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-inprocess:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-netty-shaded:1.61.1=buildScriptClasspath
io.grpc:grpc-protobuf-lite:1.61.1=buildScriptClasspath
io.grpc:grpc-protobuf:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-rls:1.61.1=buildScriptClasspath
io.grpc:grpc-services:1.61.1=buildScriptClasspath
io.grpc:grpc-stub:1.61.1=buildScriptClasspath,compileClasspath
io.grpc:grpc-util:1.61.1=buildScriptClasspath
io.grpc:grpc-xds:1.61.1=buildScriptClasspath
io.opencensus:opencensus-api:0.31.1=buildScriptClasspath,compileClasspath
io.opencensus:opencensus-contrib-http-util:0.31.1=buildScriptClasspath,compileClasspath
io.opencensus:opencensus-proto:0.2.0=buildScriptClasspath
io.perfmark:perfmark-api:0.27.0=buildScriptClasspath
javax.annotation:javax.annotation-api:1.3.2=buildScriptClasspath,compileClasspath
javax.annotation:jsr250-api:1.0=buildScriptClasspath,compileClasspath
javax.inject:javax.inject:1=annotationProcessor,buildScriptClasspath,compileClasspath
org.apache.commons:commons-lang3:3.13.0=buildScriptClasspath,compileClasspath
org.apache.commons:commons-text:1.11.0=buildScriptClasspath,compileClasspath
org.apache.httpcomponents:httpclient:4.5.14=buildScriptClasspath,compileClasspath
org.apache.httpcomponents:httpcore:4.4.16=buildScriptClasspath,compileClasspath
org.checkerframework:checker-qual:3.33.0=annotationProcessor
org.checkerframework:checker-qual:3.42.0=buildScriptClasspath,compileClasspath
org.codehaus.mojo:animal-sniffer-annotations:1.23=buildScriptClasspath
org.conscrypt:conscrypt-openjdk-uber:2.5.2=buildScriptClasspath,compileClasspath
org.json:json:20160212=buildScriptClasspath,compileClasspath
org.ow2.asm:asm-analysis:7.0=buildScriptClasspath,compileClasspath
org.ow2.asm:asm-commons:7.0=buildScriptClasspath,compileClasspath
org.ow2.asm:asm-tree:7.0=buildScriptClasspath,compileClasspath
org.ow2.asm:asm-util:7.0=buildScriptClasspath,compileClasspath
org.ow2.asm:asm:7.0=buildScriptClasspath,compileClasspath
org.pcollections:pcollections:3.1.4=annotationProcessor
org.threeten:threetenbp:1.6.8=buildScriptClasspath,compileClasspath
empty=

View File

@@ -1,3 +0,0 @@
pluginsUrl=https://plugins.gradle.org/m2/
allowInsecureProtocol=
enableDependencyLocking=true

View File

@@ -1,211 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
import static com.google.common.io.Resources.getResource;
import static google.registry.gradle.plugin.GcsPluginUtils.toByteArraySupplier;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.html.types.TrustedResourceUrls;
import com.google.template.soy.SoyFileSet;
import com.google.template.soy.tofu.SoyTofu;
import google.registry.gradle.plugin.ProjectData.TaskData;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* Creates the files for a web-page summary of a given {@link ProjectData}.
*
* <p>The main job of this class is rendering a tailored cover page that includes information about
* the project and any task that ran.
*
* <p>It returns all the files that need uploading for the cover page to work. This includes any
* report and log files linked to in the ProjectData, as well as a cover page (and associated
* resources such as CSS files).
*/
final class CoverPageGenerator {
/** List of all resource files that will be uploaded as-is. */
private static final ImmutableSet<Path> STATIC_RESOURCE_FILES =
ImmutableSet.of(Paths.get("css", "style.css"));
/** Name of the entry-point file that will be created. */
private static final Path ENTRY_POINT = Paths.get("index.html");
private final ProjectData projectData;
private final ImmutableSetMultimap<TaskData.State, TaskData> tasksByState;
/**
* The compiled SOY files.
*
* <p>Will be generated only when actually needed, because it takes a while to compile and we
* don't want that to happen unless we actually use it.
*/
private SoyTofu tofu = null;
CoverPageGenerator(ProjectData projectData) {
this.projectData = projectData;
this.tasksByState =
projectData.tasks().stream().collect(toImmutableSetMultimap(TaskData::state, task -> task));
}
/**
* Returns all the files that need uploading for the cover page to work.
*
* <p>This includes all the report files as well, to make sure that the link works.
*/
FilesWithEntryPoint getFilesToUpload() {
ImmutableMap.Builder<Path, Supplier<byte[]>> builder = new ImmutableMap.Builder<>();
// Add all the static resource pages
STATIC_RESOURCE_FILES.stream().forEach(file -> builder.put(file, resourceLoader(file)));
// Create the cover page
// Note that the ByteArraySupplier here is lazy - the createCoverPage function is only called
// when the resulting Supplier's get function is called.
builder.put(ENTRY_POINT, toByteArraySupplier(this::createCoverPage));
// Add all the files from the tasks
tasksByState.values().stream()
.flatMap(task -> task.reports().values().stream())
.forEach(reportFiles -> builder.putAll(reportFiles.files()));
// Add the logs of every test
tasksByState.values().stream()
.filter(task -> task.log().isPresent())
.forEach(task -> builder.put(getLogPath(task), task.log().get()));
return FilesWithEntryPoint.create(builder.build(), ENTRY_POINT);
}
/** Renders the cover page. */
private String createCoverPage() {
return getTofu()
.newRenderer("google.registry.gradle.plugin.coverPage")
.setData(getSoyData())
.render();
}
/** Converts the projectData and all taskData into all the data the soy template needs. */
private ImmutableMap<String, Object> getSoyData() {
ImmutableMap.Builder<String, Object> builder = new ImmutableMap.Builder<>();
TaskData.State state =
tasksByState.containsKey(TaskData.State.FAILURE)
? TaskData.State.FAILURE
: TaskData.State.SUCCESS;
String title =
state != TaskData.State.FAILURE
? "Success!"
: "Failed: "
+ tasksByState.get(state).stream()
.map(TaskData::uniqueName)
.collect(Collectors.joining(", "));
builder.put("projectState", state.toString());
builder.put("title", title);
builder.put("cssFiles", ImmutableSet.of(TrustedResourceUrls.fromConstant("css/style.css")));
builder.put("invocation", getInvocation());
builder.put("tasksByState", getTasksByStateSoyData());
return builder.build();
}
/**
* Returns a soy-friendly map from the TaskData.State to the task itslef.
*
* <p>The key order in the resulting map is always the same (the order from the enum definition)
* no matter the key order in the original tasksByState map.
*/
private ImmutableMap<String, Object> getTasksByStateSoyData() {
ImmutableMap.Builder<String, Object> builder = new ImmutableMap.Builder<>();
// We go over the States in the order they are defined rather than the order in which they
// happen to be in the tasksByState Map.
//
// That way we guarantee a consistent order.
for (TaskData.State state : TaskData.State.values()) {
builder.put(
state.toString(),
tasksByState.get(state).stream()
.map(task -> taskDataToSoy(task))
.collect(toImmutableList()));
}
return builder.build();
}
/** returns a soy-friendly version of the given task data. */
static ImmutableMap<String, Object> taskDataToSoy(TaskData task) {
// Note that all instances of File.separator are replaced with forward slashes so that we can
// generate a valid href on Windows.
return new ImmutableMap.Builder<String, Object>()
.put("uniqueName", task.uniqueName())
.put("description", task.description())
.put(
"log",
task.log().isPresent() ? getLogPath(task).toString().replace(File.separator, "/") : "")
.put(
"reports",
task.reports().entrySet().stream()
.collect(
toImmutableMap(
Map.Entry::getKey,
entry ->
entry.getValue().files().isEmpty()
? ""
: entry
.getValue()
.entryPoint()
.toString()
.replace(File.separator, "/"))))
.build();
}
private String getInvocation() {
StringBuilder builder = new StringBuilder();
builder.append("./gradlew");
projectData.tasksRequested().forEach(task -> builder.append(" ").append(task));
projectData
.projectProperties()
.forEach((key, value) -> builder.append(String.format(" -P %s=%s", key, value)));
return builder.toString();
}
/** Returns a lazily created soy renderer */
private SoyTofu getTofu() {
if (tofu == null) {
tofu =
SoyFileSet.builder()
.add(getResource(CoverPageGenerator.class, "soy/coverpage.soy"))
.build()
.compileToTofu();
}
return tofu;
}
private static Path getLogPath(TaskData task) {
// We replace colons with dashes so that the resulting filename is always valid, even in
// Windows. As a dash is not a valid character in Java identifies, a task name cannot include
// it, so the uniqueness of the name is perserved.
return Paths.get("logs", task.uniqueName().replace(":", "-") + ".log");
}
private static Supplier<byte[]> resourceLoader(Path path) {
return toByteArraySupplier(getResource(CoverPageGenerator.class, path.toString()));
}
}

View File

@@ -1,59 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import static com.google.common.base.Preconditions.checkArgument;
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableMap;
import java.nio.file.Path;
import java.util.function.Supplier;
/**
* Holds a set of files with a browser-friendly entry point to those files.
*
* <p>The file data is lazily generated.
*
* <p>If there is at least one file, it's guaranteed that the entry point is one of these files.
*/
@AutoValue
abstract class FilesWithEntryPoint {
/**
* All files that are part of this report, keyed from their path to a supplier of their content.
*
* <p>The reason we use a supplier instead of loading the content is in case the content is very
* large...
*
* <p>Also, no point in doing IO before we need it!
*/
abstract ImmutableMap<Path, Supplier<byte[]>> files();
/**
* The file that gives access (links...) to all the data in the report.
*
* <p>Guaranteed to be a key in {@link #files} if and only if files isn't empty.
*/
abstract Path entryPoint();
static FilesWithEntryPoint create(ImmutableMap<Path, Supplier<byte[]>> files, Path entryPoint) {
checkArgument(files.isEmpty() || files.containsKey(entryPoint));
return new AutoValue_FilesWithEntryPoint(files, entryPoint);
}
static FilesWithEntryPoint createSingleFile(Path path, Supplier<byte[]> data) {
return create(ImmutableMap.of(path, data), path);
}
}

View File

@@ -1,221 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.Iterables.getOnlyElement;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.Storage;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Streams;
import com.google.common.io.Files;
import com.google.common.io.Resources;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URL;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/** Utility functions used in the GCS plugin. */
final class GcsPluginUtils {
private static final ImmutableMap<String, String> EXTENSION_TO_CONTENT_TYPE =
new ImmutableMap.Builder<String, String>()
.put("html", "text/html")
.put("htm", "text/html")
.put("log", "text/plain")
.put("txt", "text/plain")
.put("css", "text/css")
.put("xml", "text/xml")
.put("zip", "application/zip")
.put("js", "text/javascript")
.build();
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
static Path toNormalizedPath(File file) {
return file.toPath().toAbsolutePath().normalize();
}
static Path toNormalizedPath(Path file) {
return file.toAbsolutePath().normalize();
}
static String getContentType(String fileName) {
return EXTENSION_TO_CONTENT_TYPE.getOrDefault(
Files.getFileExtension(fileName), DEFAULT_CONTENT_TYPE);
}
static void uploadFileToGcs(
Storage storage, String bucket, Path path, Supplier<byte[]> dataSupplier) {
// Replace Windows file separators with forward slashes.
String filename = path.toString().replace(File.separator, "/");
storage.create(
BlobInfo.newBuilder(bucket, filename).setContentType(getContentType(filename)).build(),
dataSupplier.get());
}
static void uploadFilesToGcsMultithread(
Storage storage, String bucket, Path folder, Map<Path, Supplier<byte[]>> files) {
ImmutableMap.Builder<Path, Thread> threads = new ImmutableMap.Builder<>();
files.forEach(
(path, dataSupplier) -> {
Thread thread =
new Thread(
() -> uploadFileToGcs(storage, bucket, folder.resolve(path), dataSupplier));
thread.start();
threads.put(path, thread);
});
threads
.build()
.forEach(
(path, thread) -> {
try {
thread.join();
} catch (InterruptedException e) {
System.out.format("Upload of %s interrupted", path);
}
});
}
static Supplier<byte[]> toByteArraySupplier(String data) {
return () -> data.getBytes(UTF_8);
}
static Supplier<byte[]> toByteArraySupplier(Supplier<String> dataSupplier) {
return () -> dataSupplier.get().getBytes(UTF_8);
}
static Supplier<byte[]> toByteArraySupplier(File file) {
return () -> {
try {
return Files.toByteArray(file);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}
static Supplier<byte[]> toByteArraySupplier(URL url) {
return () -> {
try {
return Resources.toByteArray(url);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}
/**
* Reads all the files generated by a Report into a FilesWithEntryPoint object.
*
* <p>Every FilesWithEntryPoint must have a single link "entry point" that gives users access to
* all the files. If the report generated just one file - we will just link to that file.
*
* <p>However, if the report generated more than one file - the only thing we can safely do is to
* zip all the files and link to the zip file.
*
* <p>As an alternative to using a zip file, we allow the caller to supply an optional "entry
* point" file that will link to all the other files. If that file is given and is "appropriate"
* (exists and is in the correct location) - we will upload all the report files "as is" and link
* to the entry file.
*
* @param destination the location of the output. Either a file or a directory. If a directory -
* then all the files inside that directory are the outputs we're looking for.
* @param entryPointHint If present - a hint to what the entry point to this directory tree is.
* Will only be used if all of the following apply: (a) {@code
* destination.isDirectory()==true}, (b) there are 2 or more files in the {@code destination}
* directory, and (c) {@code entryPointHint.get()} is one of the files nested inside of the
* {@code destination} directory.
*/
static FilesWithEntryPoint readFilesWithEntryPoint(
File destination, Optional<File> entryPointHint, Path rootDir) {
Path destinationPath = rootDir.relativize(toNormalizedPath(destination));
if (destination.isFile()) {
// The destination is a single file - find its root, and add this single file to the
// FilesWithEntryPoint.
return FilesWithEntryPoint.createSingleFile(
destinationPath, toByteArraySupplier(destination));
}
if (!destination.isDirectory()) {
// This isn't a file nor a directory - so it doesn't exist! Return empty FilesWithEntryPoint
return FilesWithEntryPoint.create(ImmutableMap.of(), destinationPath);
}
// The destination is a directory - find all the actual files first
ImmutableMap<Path, Supplier<byte[]>> files =
Streams.stream(Files.fileTraverser().depthFirstPreOrder(destination))
.filter(File::isFile)
.collect(
toImmutableMap(
file -> rootDir.relativize(toNormalizedPath(file)),
GcsPluginUtils::toByteArraySupplier));
if (files.isEmpty()) {
// The directory exists, but is empty. Return empty FilesWithEntryPoint
return FilesWithEntryPoint.create(ImmutableMap.of(), destinationPath);
}
if (files.size() == 1) {
// We got a directory, but it only has a single file. We can link to that.
return FilesWithEntryPoint.create(files, getOnlyElement(files.keySet()));
}
// There are multiple files in the report! We need to check the entryPointHint
Optional<Path> entryPointPath =
entryPointHint.map(file -> rootDir.relativize(toNormalizedPath(file)));
if (entryPointPath.isPresent() && files.containsKey(entryPointPath.get())) {
// We were given the entry point! Use it!
return FilesWithEntryPoint.create(files, entryPointPath.get());
}
// We weren't given an appropriate entry point. But we still need a single link to all this data
// - so we'll zip it and just host a single file.
Path zipFilePath = destinationPath.resolve(destinationPath.getFileName().toString() + ".zip");
return FilesWithEntryPoint.createSingleFile(zipFilePath, createZippedByteArraySupplier(files));
}
static Supplier<byte[]> createZippedByteArraySupplier(Map<Path, Supplier<byte[]>> files) {
return () -> zipFiles(files);
}
private static byte[] zipFiles(Map<Path, Supplier<byte[]>> files) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output)) {
for (Path path : files.keySet()) {
zip.putNextEntry(new ZipEntry(path.toString()));
zip.write(files.get(path).get());
zip.closeEntry();
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return output.toByteArray();
}
private GcsPluginUtils() {}
}

View File

@@ -1,137 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
/**
* All the data of a root Gradle project.
*
* <p>This is basically all the "relevant" data from a Gradle Project, arranged in an immutable and
* more convenient way.
*/
@AutoValue
abstract class ProjectData {
abstract String name();
abstract String description();
abstract String gradleVersion();
abstract ImmutableMap<String, String> projectProperties();
abstract ImmutableMap<String, String> systemProperties();
abstract ImmutableSet<String> tasksRequested();
abstract ImmutableSet<TaskData> tasks();
abstract Builder toBuilder();
static Builder builder() {
return new AutoValue_ProjectData.Builder();
}
@AutoValue.Builder
abstract static class Builder {
abstract Builder setName(String name);
abstract Builder setDescription(String description);
abstract Builder setGradleVersion(String gradleVersion);
abstract Builder setProjectProperties(Map<String, String> projectProperties);
abstract Builder setSystemProperties(Map<String, String> systemProperties);
abstract Builder setTasksRequested(Iterable<String> tasksRequested);
abstract ImmutableSet.Builder<TaskData> tasksBuilder();
Builder addTask(TaskData task) {
tasksBuilder().add(task);
return this;
}
abstract ProjectData build();
}
/**
* Relevant data to a single Task's.
*
* <p>Some Tasks are also "Reporting", meaning they create file outputs we want to upload in
* various formats. The format that interests us the most is "html", as that's nicely browsable,
* but they might also have other formats.
*/
@AutoValue
abstract static class TaskData {
enum State {
/** The task has failed for some reason. */
FAILURE,
/** The task was actually run and has finished successfully. */
SUCCESS,
/** The task was up-to-date and successful, and hence didn't need to run again. */
UP_TO_DATE
}
abstract String uniqueName();
abstract String description();
abstract State state();
abstract Optional<Supplier<byte[]>> log();
/**
* Returns the FilesWithEntryPoint for every report, keyed on the report type.
*
* <p>The "html" report type is the most interesting, but there are other report formats.
*/
abstract ImmutableMap<String, FilesWithEntryPoint> reports();
abstract Builder toBuilder();
static Builder builder() {
return new AutoValue_ProjectData_TaskData.Builder();
}
@AutoValue.Builder
abstract static class Builder {
abstract Builder setUniqueName(String name);
abstract Builder setDescription(String description);
abstract Builder setState(State state);
abstract Builder setLog(Supplier<byte[]> log);
abstract ImmutableMap.Builder<String, FilesWithEntryPoint> reportsBuilder();
Builder putReport(String type, FilesWithEntryPoint reportFiles) {
reportsBuilder().put(type, reportFiles);
return this;
}
abstract TaskData build();
}
}
}

View File

@@ -1,311 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Strings.isNullOrEmpty;
import static google.registry.gradle.plugin.GcsPluginUtils.readFilesWithEntryPoint;
import static google.registry.gradle.plugin.GcsPluginUtils.toByteArraySupplier;
import static google.registry.gradle.plugin.GcsPluginUtils.toNormalizedPath;
import static google.registry.gradle.plugin.GcsPluginUtils.uploadFileToGcs;
import static google.registry.gradle.plugin.GcsPluginUtils.uploadFilesToGcsMultithread;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.StorageOptions;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.Files;
import google.registry.gradle.plugin.ProjectData.TaskData;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Supplier;
import org.gradle.api.DefaultTask;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.reporting.DirectoryReport;
import org.gradle.api.reporting.Report;
import org.gradle.api.reporting.ReportContainer;
import org.gradle.api.reporting.Reporting;
import org.gradle.api.tasks.TaskAction;
/** A task that uploads the Reports generated by other tasks to GCS. */
public class ReportUploader extends DefaultTask {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final ImmutableMap<String, BiConsumer<ReportUploader, String>> UPLOAD_FUNCTIONS =
ImmutableMap.of(
"file://", ReportUploader::saveResultsToLocalFolder,
"gcs://", ReportUploader::uploadResultsToGcs);
private final ArrayList<Task> tasks = new ArrayList<>();
private final HashMap<String, StringBuilder> logs = new HashMap<>();
private Project project;
private String destination = null;
private String credentialsFile = null;
private String multithreadedUpload = null;
/**
* Sets the destination of the reports.
*
* <p>Currently supports two types of destinations:
*
* <ul>
* <li>file://[absulute local path], e.g. file:///tmp/buildOutputs/
* <li>gcs://[bucket name]/[optional path], e.g. gcs://my-bucket/buildOutputs/
* </ul>
*/
public void setDestination(String destination) {
this.destination = destination;
}
public void setCredentialsFile(String credentialsFile) {
this.credentialsFile = credentialsFile;
}
public void setMultithreadedUpload(String multithreadedUpload) {
this.multithreadedUpload = multithreadedUpload;
}
/** Converts the given Gradle Project into a ProjectData. */
private ProjectData createProjectData() {
ProjectData.Builder builder =
ProjectData.builder()
.setName(project.getPath() + project.getName())
.setDescription(
Optional.ofNullable(project.getDescription()).orElse("[No description available]"))
.setGradleVersion(project.getGradle().getGradleVersion())
.setProjectProperties(project.getGradle().getStartParameter().getProjectProperties())
.setSystemProperties(project.getGradle().getStartParameter().getSystemPropertiesArgs())
.setTasksRequested(project.getGradle().getStartParameter().getTaskNames());
Path rootDir = toNormalizedPath(project.getRootDir());
tasks.stream()
.filter(task -> task.getState().getExecuted() || task.getState().getUpToDate())
.map(task -> createTaskData(task, rootDir))
.forEach(builder.tasksBuilder()::add);
return builder.build();
}
/**
* Converts a Gradle Task into a TaskData.
*
* @param rootDir the root directory of the main Project - used to get the relative path of any
* Task files.
*/
private TaskData createTaskData(Task task, Path rootDir) {
TaskData.State state =
task.getState().getFailure() != null
? TaskData.State.FAILURE
: task.getState().getUpToDate() ? TaskData.State.UP_TO_DATE : TaskData.State.SUCCESS;
String log = logs.get(task.getPath()).toString();
TaskData.Builder builder =
TaskData.builder()
.setState(state)
.setUniqueName(task.getPath())
.setDescription(
Optional.ofNullable(task.getDescription()).orElse("[No description available]"));
if (!log.isEmpty()) {
builder.setLog(toByteArraySupplier(log));
}
Reporting<? extends ReportContainer<? extends Report>> reporting = asReporting(task);
if (reporting != null) {
// This Task is also a Reporting task! It has a destination file/directory for every supported
// format.
// Add the files for each of the formats into the ReportData.
reporting
.getReports()
.getAsMap()
.forEach(
(type, report) -> {
File destination = report.getOutputLocation().get().getAsFile();
// The destination could be a file, or a directory. If it's a directory - the Report
// could have created multiple files - and we need to know to which one of those to
// link.
//
// If we're lucky, whoever implemented the Report made sure to extend
// DirectoryReport, which gives us the entry point to all the files.
//
// This isn't guaranteed though, as it depends on the implementer.
Optional<File> entryPointHint =
destination.isDirectory() && (report instanceof DirectoryReport)
? Optional.ofNullable(((DirectoryReport) report).getEntryPoint())
: Optional.empty();
builder
.reportsBuilder()
.put(type, readFilesWithEntryPoint(destination, entryPointHint, rootDir));
});
}
return builder.build();
}
private FilesWithEntryPoint generateFilesToUpload() {
ProjectData projectData = createProjectData();
CoverPageGenerator coverPageGenerator = new CoverPageGenerator(projectData);
return coverPageGenerator.getFilesToUpload();
}
@TaskAction
void uploadResults() {
System.out.format("ReportUploader: destination= '%s'\n", destination);
try {
if (isNullOrEmpty(destination)) {
System.out.format("ReportUploader: no destination given, skipping...\n");
return;
}
for (String key : UPLOAD_FUNCTIONS.keySet()) {
if (destination.startsWith(key)) {
UPLOAD_FUNCTIONS.get(key).accept(this, destination.substring(key.length()));
return;
}
}
System.out.format(
"ReportUploader: given destination '%s' doesn't start with one of %s."
+ " Defaulting to saving in /tmp\n",
destination, UPLOAD_FUNCTIONS.keySet());
saveResultsToLocalFolder("/tmp/");
} catch (Throwable e) {
System.out.format("ReportUploader: Encountered error %s\n", e);
e.printStackTrace(System.out);
System.out.format("ReportUploader: skipping upload\n");
}
}
private void saveResultsToLocalFolder(String absoluteFolderName) {
Path folder = Paths.get(absoluteFolderName, createUniqueFolderName());
checkArgument(
folder.isAbsolute(),
"Local files destination must be an absolute path, but is %s",
absoluteFolderName);
FilesWithEntryPoint filesToUpload = generateFilesToUpload();
System.out.format(
"ReportUploader: going to save %s files to %s\n", filesToUpload.files().size(), folder);
filesToUpload
.files()
.forEach((path, dataSupplier) -> saveFile(folder.resolve(path), dataSupplier));
System.out.format(
"ReportUploader: report saved to file://%s\n", folder.resolve(filesToUpload.entryPoint()));
}
private void saveFile(Path path, Supplier<byte[]> dataSupplier) {
File dir = path.getParent().toFile();
if (!dir.isDirectory()) {
checkState(dir.mkdirs(), "Couldn't create directory %s", dir);
}
try {
Files.write(dataSupplier.get(), path.toFile());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private void uploadResultsToGcs(String destination) {
checkArgument(
!destination.isEmpty(), "destination must include at least the bucket name, but is empty");
Path bucketWithFolder = Paths.get(destination, createUniqueFolderName());
String bucket = bucketWithFolder.getName(0).toString();
Path folder = bucketWithFolder.subpath(1, bucketWithFolder.getNameCount());
StorageOptions.Builder storageOptions = StorageOptions.newBuilder();
if (!isNullOrEmpty(credentialsFile)) {
try {
storageOptions.setCredentials(
GoogleCredentials.fromStream(new FileInputStream(credentialsFile)));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
Storage storage = storageOptions.build().getService();
FilesWithEntryPoint filesToUpload = generateFilesToUpload();
System.out.format(
"ReportUploader: going to upload %s files to %s/%s\n",
filesToUpload.files().size(), bucket, folder);
if ("yes".equals(multithreadedUpload)) {
System.out.format("ReportUploader: multi-threaded upload\n");
uploadFilesToGcsMultithread(storage, bucket, folder, filesToUpload.files());
} else {
System.out.format("ReportUploader: single threaded upload\n");
filesToUpload
.files()
.forEach(
(path, dataSupplier) -> {
System.out.format("ReportUploader: Uploading %s\n", path);
uploadFileToGcs(storage, bucket, folder.resolve(path), dataSupplier);
});
}
System.out.format(
"ReportUploader: report uploaded to https://storage.googleapis.com/%s/%s\n",
bucket, folder.resolve(filesToUpload.entryPoint()));
}
void setProject(Project project) {
this.project = project;
for (Project subProject : project.getAllprojects()) {
subProject.getTasks().all(this::addTask);
}
}
private void addTask(Task task) {
if (task instanceof ReportUploader) {
return;
}
tasks.add(task);
StringBuilder log = new StringBuilder();
checkArgument(
!logs.containsKey(task.getPath()),
"Multiple tasks with the same .getPath()=%s",
task.getPath());
logs.put(task.getPath(), log);
task.getLogging().addStandardOutputListener(output -> log.append(output));
task.getLogging().addStandardErrorListener(output -> log.append(output));
task.finalizedBy(this);
}
@SuppressWarnings("unchecked")
private static Reporting<? extends ReportContainer<? extends Report>> asReporting(Task task) {
if (task instanceof Reporting) {
return (Reporting<? extends ReportContainer<? extends Report>>) task;
}
return null;
}
private String createUniqueFolderName() {
return String.format(
"%h-%h-%h-%h",
SECURE_RANDOM.nextInt(),
SECURE_RANDOM.nextInt(),
SECURE_RANDOM.nextInt(),
SECURE_RANDOM.nextInt());
}
}

View File

@@ -1,47 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
/**
* Plugin setting up the ReportUploader task.
*
* <p>It goes over all the tasks in a project and pass them on to the ReportUploader task for set
* up.
*
* <p>Note that since we're passing in all the projects' tasks - this includes the ReportUploader
* itself! It's up to the ReportUploader to take care of not having "infinite loops" caused by
* waiting for itself to end before finishing.
*/
public class ReportUploaderPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
ReportUploader reportUploader =
project
.getTasks()
.create(
"reportUploader",
ReportUploader.class,
task -> {
task.setDescription("Uploads the reports to GCS bucket");
task.setGroup("uploads");
});
reportUploader.setProject(project);
}
}

View File

@@ -1,27 +0,0 @@
body {
font-family: sans-serif;
}
.task_state_SUCCESS {
color: green;
}
.task_state_FAILURE {
color: red;
}
.task_name {
display: block;
font-size: larger;
font-weight: bold;
}
.task_description {
display: block;
margin-left: 1em;
color: gray;
}
.report_links {
margin-left: 1em;
}
.report_link_broken {
text-decoration: line-through;
color: gray;
}

View File

@@ -1,107 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
{namespace google.registry.gradle.plugin}
{template .coverPage}
{@param title: string}
{@param cssFiles: list<trusted_resource_uri>}
{@param projectState: string}
{@param invocation: string}
{@param tasksByState: map<string, list<[uniqueName: string, description: string, log: string, reports: map<string, string>]>>}
<title>{$title}</title>
{for $cssFile in $cssFiles}
<link rel="stylesheet" type="text/css" href="{$cssFile}">
{/for}
<body>
<div class="project">
<h1 class="project_title {'task_state_' + $projectState}">{$title}</h1>
<span class="project_subtitle">
Build results for <span class="project_invocation">{$invocation}</span>
</span>
{for $taskState in mapKeys($tasksByState)}
{if length($tasksByState[$taskState]) > 0}
{call .tasksOfState}
{param state: $taskState /}
{param tasks: $tasksByState[$taskState] /}
{/call}
{/if}
{/for}
</div>
</body>
{/template}
{template .tasksOfState}
{@param state: string}
{@param tasks: list<[uniqueName: string, description: string, log: string, reports: map<string, string>]>}
<div class="{'task_state_' + $state}">
<p>{$state}</p>
// Place the tasks with actual reports first, since those are more likely to be useful
{for $task in $tasks}
{if length(mapKeys($task.reports)) > 0}
{call .task}
{param task: $task /}
{/call}
{/if}
{/for}
// Followup with reports without links
{for $task in $tasks}
{if length(mapKeys($task.reports)) == 0}
{call .task}
{param task: $task /}
{/call}
{/if}
{/for}
</div>
{/template}
{template .task}
{@param task: [uniqueName: string, description: string, log: string, reports: map<string, string>]}
{call .taskInternal}
{param uniqueName: $task.uniqueName /}
{param description: $task.description /}
{param log: $task.log /}
{param reports: $task.reports /}
{/call}
{/template}
{template .taskInternal}
{@param uniqueName: string}
{@param description: string}
{@param log: string}
{@param reports: map<string, string>}
<div class="task">
<span class="task_name">{$uniqueName}</span>
<span class="task_description">{$description}</span>
<span class="report_links">
{if $log}
<a href="{$log}">[log]</a>
{else}
<span class="report_link_broken">[log]</span>
{/if}
{for $type in mapKeys($reports)}
{if $reports[$type]}
<a href="{$reports[$type]}">[{$type}]</a>
{else}
<span class="report_link_broken">[{$type}]</span>
{/if}
{/for}
</span>
</div>
{/template}

View File

@@ -1,278 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.gradle.plugin.GcsPluginUtils.toByteArraySupplier;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import google.registry.gradle.plugin.ProjectData.TaskData;
import java.io.File;
import java.nio.file.Paths;
import org.junit.jupiter.api.Test;
/** Tests for {@link CoverPageGenerator} */
final class CoverPageGeneratorTest {
private static final ProjectData EMPTY_PROJECT =
ProjectData.builder()
.setName("project-name")
.setDescription("project-description")
.setGradleVersion("gradle-version")
.setProjectProperties(ImmutableMap.of("key", "value"))
.setSystemProperties(ImmutableMap.of())
.setTasksRequested(ImmutableSet.of(":a:task1", ":a:task2"))
.build();
private static final TaskData EMPTY_TASK_SUCCESS =
TaskData.builder()
.setUniqueName("task-success")
.setDescription("a successful task")
.setState(TaskData.State.SUCCESS)
.build();
private static final TaskData EMPTY_TASK_FAILURE =
TaskData.builder()
.setUniqueName("task-failure")
.setDescription("a failed task")
.setState(TaskData.State.FAILURE)
.build();
private static final TaskData EMPTY_TASK_UP_TO_DATE =
TaskData.builder()
.setUniqueName("task-up-to-date")
.setDescription("an up-to-date task")
.setState(TaskData.State.UP_TO_DATE)
.build();
private static final Joiner filenameJoiner = Joiner.on(File.separator);
private ImmutableMap<String, String> getGeneratedFiles(ProjectData project) {
CoverPageGenerator coverPageGenerator = new CoverPageGenerator(project);
FilesWithEntryPoint files = coverPageGenerator.getFilesToUpload();
return files.files().entrySet().stream()
.collect(
toImmutableMap(
entry -> entry.getKey().toString(),
entry -> new String(entry.getValue().get(), UTF_8)));
}
private String getContentOfGeneratedFile(ProjectData project, String expectedPath) {
ImmutableMap<String, String> files = getGeneratedFiles(project);
assertThat(files).containsKey(expectedPath);
return files.get(expectedPath);
}
private String getCoverPage(ProjectData project) {
return getContentOfGeneratedFile(project, "index.html");
}
@Test
void testGetFilesToUpload_entryPoint_isIndexHtml() {
CoverPageGenerator coverPageGenerator = new CoverPageGenerator(EMPTY_PROJECT);
assertThat(coverPageGenerator.getFilesToUpload().entryPoint())
.isEqualTo(Paths.get("index.html"));
}
@Test
void testGetFilesToUpload_containsEntryFile() {
String content = getContentOfGeneratedFile(EMPTY_PROJECT, "index.html");
assertThat(content)
.contains(
"<span class=\"project_invocation\">./gradlew :a:task1 :a:task2 -P key=value</span>");
}
@Test
void testCoverPage_showsFailedTask() {
String content = getCoverPage(EMPTY_PROJECT.toBuilder().addTask(EMPTY_TASK_FAILURE).build());
assertThat(content).contains("task-failure");
assertThat(content).contains("<p>FAILURE</p>");
assertThat(content).doesNotContain("<p>SUCCESS</p>");
assertThat(content).doesNotContain("<p>UP_TO_DATE</p>");
}
@Test
void testCoverPage_showsSuccessfulTask() {
String content = getCoverPage(EMPTY_PROJECT.toBuilder().addTask(EMPTY_TASK_SUCCESS).build());
assertThat(content).contains("task-success");
assertThat(content).doesNotContain("<p>FAILURE</p>");
assertThat(content).contains("<p>SUCCESS</p>");
assertThat(content).doesNotContain("<p>UP_TO_DATE</p>");
}
@Test
void testCoverPage_showsUpToDateTask() {
String content = getCoverPage(EMPTY_PROJECT.toBuilder().addTask(EMPTY_TASK_UP_TO_DATE).build());
assertThat(content).contains("task-up-to-date");
assertThat(content).doesNotContain("<p>FAILURE</p>");
assertThat(content).doesNotContain("<p>SUCCESS</p>");
assertThat(content).contains("<p>UP_TO_DATE</p>");
}
@Test
void testCoverPage_failedAreFirst() {
String content =
getCoverPage(
EMPTY_PROJECT.toBuilder()
.addTask(EMPTY_TASK_UP_TO_DATE)
.addTask(EMPTY_TASK_FAILURE)
.addTask(EMPTY_TASK_SUCCESS)
.build());
assertThat(content).contains("<p>FAILURE</p>");
assertThat(content).contains("<p>SUCCESS</p>");
assertThat(content).contains("<p>UP_TO_DATE</p>");
assertThat(content).containsMatch("(?s)<p>FAILURE</p>.*<p>SUCCESS</p>");
assertThat(content).containsMatch("(?s)<p>FAILURE</p>.*<p>UP_TO_DATE</p>");
assertThat(content).doesNotContainMatch("(?s)<p>SUCCESS</p>.*<p>FAILURE</p>");
assertThat(content).doesNotContainMatch("(?s)<p>UP_TO_DATE</p>.*<p>FAILURE</p>");
}
@Test
void testCoverPage_failingTask_statusIsFailure() {
String content =
getCoverPage(
EMPTY_PROJECT.toBuilder()
.addTask(EMPTY_TASK_UP_TO_DATE)
.addTask(EMPTY_TASK_FAILURE)
.addTask(EMPTY_TASK_SUCCESS)
.build());
assertThat(content).contains("<title>Failed: task-failure</title>");
}
@Test
void testCoverPage_noFailingTask_statusIsSuccess() {
String content =
getCoverPage(
EMPTY_PROJECT.toBuilder()
.addTask(EMPTY_TASK_UP_TO_DATE)
.addTask(EMPTY_TASK_SUCCESS)
.build());
assertThat(content).contains("<title>Success!</title>");
}
@Test
void testGetFilesToUpload_containsCssFile() {
ImmutableMap<String, String> files = getGeneratedFiles(EMPTY_PROJECT);
assertThat(files).containsKey(filenameJoiner.join("css", "style.css"));
assertThat(files.get(filenameJoiner.join("css", "style.css"))).contains("body {");
assertThat(files.get("index.html"))
.contains("<link rel=\"stylesheet\" type=\"text/css\" href=\"css/style.css\">");
}
@Test
void testCreateReportFiles_taskWithLog() {
ImmutableMap<String, String> files =
getGeneratedFiles(
EMPTY_PROJECT.toBuilder()
.addTask(
EMPTY_TASK_SUCCESS.toBuilder()
.setUniqueName("my:name")
.setLog(toByteArraySupplier("my log data"))
.build())
.build());
assertThat(files).containsEntry(filenameJoiner.join("logs", "my-name.log"), "my log data");
assertThat(files.get("index.html")).contains("<a href=\"logs/my-name.log\">[log]</a>");
}
@Test
void testCreateReportFiles_taskWithoutLog() {
ImmutableMap<String, String> files =
getGeneratedFiles(
EMPTY_PROJECT.toBuilder()
.addTask(EMPTY_TASK_SUCCESS.toBuilder().setUniqueName("my:name").build())
.build());
assertThat(files).doesNotContainKey("logs/my-name.log");
assertThat(files.get("index.html")).contains("<span class=\"report_link_broken\">[log]</span>");
}
@Test
void testCreateReportFiles_taskWithFilledReport() {
ImmutableMap<String, String> files =
getGeneratedFiles(
EMPTY_PROJECT.toBuilder()
.addTask(
EMPTY_TASK_SUCCESS.toBuilder()
.putReport(
"someReport",
FilesWithEntryPoint.create(
ImmutableMap.of(
Paths.get("path", "report.txt"),
toByteArraySupplier("report content")),
Paths.get("path", "report.txt")))
.build())
.build());
assertThat(files).containsEntry(filenameJoiner.join("path", "report.txt"), "report content");
assertThat(files.get("index.html")).contains("<a href=\"path/report.txt\">[someReport]</a>");
}
@Test
void testCreateReportFiles_taskWithEmptyReport() {
ImmutableMap<String, String> files =
getGeneratedFiles(
EMPTY_PROJECT.toBuilder()
.addTask(
EMPTY_TASK_SUCCESS.toBuilder()
.putReport(
"someReport",
FilesWithEntryPoint.create(
ImmutableMap.of(), Paths.get("path", "report.txt")))
.build())
.build());
assertThat(files).doesNotContainKey(filenameJoiner.join("path", "report.txt"));
assertThat(files.get("index.html"))
.contains("<span class=\"report_link_broken\">[someReport]</span>");
}
@Test
void testCreateReportFiles_taskWithLogAndMultipleReports() {
ImmutableMap<String, String> files =
getGeneratedFiles(
EMPTY_PROJECT.toBuilder()
.addTask(
EMPTY_TASK_SUCCESS.toBuilder()
.setUniqueName("my:name")
.setLog(toByteArraySupplier("log data"))
.putReport(
"filledReport",
FilesWithEntryPoint.create(
ImmutableMap.of(
Paths.get("path-filled", "report.txt"),
toByteArraySupplier("report content"),
Paths.get("path-filled", "other", "file.txt"),
toByteArraySupplier("some other content")),
Paths.get("path-filled", "report.txt")))
.putReport(
"emptyReport",
FilesWithEntryPoint.create(
ImmutableMap.of(), Paths.get("path-empty", "report.txt")))
.build())
.build());
assertThat(files)
.containsEntry(filenameJoiner.join("path-filled", "report.txt"), "report content");
assertThat(files)
.containsEntry(
filenameJoiner.join("path-filled", "other", "file.txt"), "some other content");
assertThat(files).containsEntry(filenameJoiner.join("logs", "my-name.log"), "log data");
assertThat(files.get("index.html"))
.contains("<a href=\"path-filled/report.txt\">[filledReport]</a>");
assertThat(files.get("index.html")).contains("<a href=\"logs/my-name.log\">[log]</a>");
assertThat(files.get("index.html"))
.contains("<span class=\"report_link_broken\">[emptyReport]</span>");
}
}

View File

@@ -1,298 +0,0 @@
// Copyright 2019 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.gradle.plugin;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.gradle.plugin.GcsPluginUtils.getContentType;
import static google.registry.gradle.plugin.GcsPluginUtils.readFilesWithEntryPoint;
import static google.registry.gradle.plugin.GcsPluginUtils.toByteArraySupplier;
import static google.registry.gradle.plugin.GcsPluginUtils.toNormalizedPath;
import static google.registry.gradle.plugin.GcsPluginUtils.uploadFileToGcs;
import static google.registry.gradle.plugin.GcsPluginUtils.uploadFilesToGcsMultithread;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.Files.createDirectories;
import static java.nio.file.Files.createDirectory;
import static java.nio.file.Files.createFile;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.Storage;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableMap;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Tests for {@link GcsPluginUtilsTest} */
final class GcsPluginUtilsTest {
private static final Joiner filenameJoiner = Joiner.on(File.separator);
@SuppressWarnings("WeakerAccess")
@TempDir
Path tmpDir;
@Test
void testGetContentType_knownTypes() {
assertThat(getContentType("path/to/file.html")).isEqualTo("text/html");
assertThat(getContentType("path/to/file.htm")).isEqualTo("text/html");
assertThat(getContentType("path/to/file.log")).isEqualTo("text/plain");
assertThat(getContentType("path/to/file.txt")).isEqualTo("text/plain");
assertThat(getContentType("path/to/file.css")).isEqualTo("text/css");
assertThat(getContentType("path/to/file.xml")).isEqualTo("text/xml");
assertThat(getContentType("path/to/file.zip")).isEqualTo("application/zip");
assertThat(getContentType("path/to/file.js")).isEqualTo("text/javascript");
}
@Test
void testGetContentType_unknownTypes() {
assertThat(getContentType("path/to/file.unknown")).isEqualTo("application/octet-stream");
}
@Test
void testUploadFileToGcs() {
Storage storage = mock(Storage.class);
uploadFileToGcs(
storage, "my-bucket", Paths.get("my", "filename.txt"), toByteArraySupplier("my data"));
verify(storage)
.create(
BlobInfo.newBuilder("my-bucket", "my/filename.txt")
.setContentType("text/plain")
.build(),
"my data".getBytes(UTF_8));
verifyNoMoreInteractions(storage);
}
@Test
void testUploadFilesToGcsMultithread() {
Storage storage = mock(Storage.class);
uploadFilesToGcsMultithread(
storage,
"my-bucket",
Paths.get("my", "folder"),
ImmutableMap.of(
Paths.get("some", "index.html"), toByteArraySupplier("some web page"),
Paths.get("some", "style.css"), toByteArraySupplier("some style"),
Paths.get("other", "index.html"), toByteArraySupplier("other web page"),
Paths.get("other", "style.css"), toByteArraySupplier("other style")));
verify(storage)
.create(
BlobInfo.newBuilder("my-bucket", "my/folder/some/index.html")
.setContentType("text/html")
.build(),
"some web page".getBytes(UTF_8));
verify(storage)
.create(
BlobInfo.newBuilder("my-bucket", "my/folder/some/style.css")
.setContentType("text/css")
.build(),
"some style".getBytes(UTF_8));
verify(storage)
.create(
BlobInfo.newBuilder("my-bucket", "my/folder/other/index.html")
.setContentType("text/html")
.build(),
"other web page".getBytes(UTF_8));
verify(storage)
.create(
BlobInfo.newBuilder("my-bucket", "my/folder/other/style.css")
.setContentType("text/css")
.build(),
"other style".getBytes(UTF_8));
verifyNoMoreInteractions(storage);
}
@Test
void testToByteArraySupplier_string() {
assertThat(toByteArraySupplier("my string").get()).isEqualTo("my string".getBytes(UTF_8));
}
@Test
void testToByteArraySupplier_stringSupplier() {
assertThat(toByteArraySupplier(() -> "my string").get()).isEqualTo("my string".getBytes(UTF_8));
}
@Test
void testToByteArraySupplier_file() throws Exception {
Path dir = createDirectory(tmpDir.resolve("arbitrary"));
Path file = createFile(dir.resolve("file.txt"));
Files.write(file, "some data".getBytes(UTF_8));
assertThat(toByteArraySupplier(file.toFile()).get()).isEqualTo("some data".getBytes(UTF_8));
}
private ImmutableMap<String, String> readAllFiles(FilesWithEntryPoint reportFiles) {
return reportFiles.files().entrySet().stream()
.collect(
toImmutableMap(
entry -> entry.getKey().toString(),
entry -> new String(entry.getValue().get(), UTF_8)));
}
@Test
void testCreateReportFiles_destinationIsFile() throws Exception {
Path root = toNormalizedPath(createDirectories(tmpDir.resolve("my/root")).toAbsolutePath());
Path somePath = createDirectories(root.resolve("some/path"));
Path destination = createFile(somePath.resolve("file.txt"));
Files.write(destination, "some data".getBytes(UTF_8));
// Since the entry point is obvious here - any hint given is just ignored.
File ignoredHint = createFile(root.resolve("ignored.txt")).toFile();
FilesWithEntryPoint files =
readFilesWithEntryPoint(destination.toFile(), Optional.of(ignoredHint), root);
assertThat(files.entryPoint().toString())
.isEqualTo(filenameJoiner.join("some", "path", "file.txt"));
assertThat(readAllFiles(files))
.containsExactly(filenameJoiner.join("some", "path", "file.txt"), "some data");
}
@Test
void testCreateReportFiles_destinationDoesntExist() throws Exception {
Path root = toNormalizedPath(createDirectories(tmpDir.resolve("my/root")).toAbsolutePath());
File destination = root.resolve("non/existing.txt").toFile();
assertThat(destination.isFile()).isFalse();
assertThat(destination.isDirectory()).isFalse();
// Since there are no files, any hint given is obviously wrong and will be ignored.
File ignoredHint = createFile(root.resolve("ignored.txt")).toFile();
FilesWithEntryPoint files =
readFilesWithEntryPoint(destination, Optional.of(ignoredHint), root);
assertThat(files.entryPoint().toString()).isEqualTo(filenameJoiner.join("non", "existing.txt"));
assertThat(files.files()).isEmpty();
}
@Test
void testCreateReportFiles_noFiles() throws Exception {
Path root = toNormalizedPath(createDirectories(tmpDir.resolve("my/root")).toAbsolutePath());
Path destination = createDirectories(root.resolve("some/path"));
createDirectories(destination.resolve("a/b"));
createDirectory(destination.resolve("c"));
// Since there are not files, any hint given is obviously wrong and will be ignored.
File ignoredHint = createFile(root.resolve("ignored.txt")).toFile();
FilesWithEntryPoint files =
readFilesWithEntryPoint(destination.toFile(), Optional.of(ignoredHint), root);
assertThat(files.entryPoint().toString()).isEqualTo(filenameJoiner.join("some", "path"));
assertThat(files.files()).isEmpty();
}
@Test
void testCreateReportFiles_oneFile() throws Exception {
Path root = toNormalizedPath(createDirectories(tmpDir.resolve("my/root")).toAbsolutePath());
Path destination = createDirectories(root.resolve("some/path"));
createDirectories(destination.resolve("a/b"));
createDirectory(destination.resolve("c"));
Files.write(createFile(destination.resolve("a/file.txt")), "some data".getBytes(UTF_8));
// Since the entry point is obvious here - any hint given is just ignored.
File ignoredHint = createFile(root.resolve("ignored.txt")).toFile();
FilesWithEntryPoint files =
readFilesWithEntryPoint(destination.toFile(), Optional.of(ignoredHint), root);
assertThat(files.entryPoint().toString())
.isEqualTo(filenameJoiner.join("some", "path", "a", "file.txt"));
assertThat(readAllFiles(files))
.containsExactly(filenameJoiner.join("some", "path", "a", "file.txt"), "some data");
}
/**
* Currently tests the "unimplemented" behavior.
*
* <p>TODO(guyben): switch to checking zip file instead.
*/
@Test
void testCreateReportFiles_multipleFiles_noHint() throws Exception {
Path root = toNormalizedPath(createDirectories(tmpDir.resolve("my/root")).toAbsolutePath());
Path destination = createDirectories(root.resolve("some/path"));
createDirectories(destination.resolve("a/b"));
createDirectory(destination.resolve("c"));
Files.write(createFile(destination.resolve("index.html")), "some data".getBytes(UTF_8));
Files.write(createFile(destination.resolve("a/index.html")), "wrong index".getBytes(UTF_8));
Files.write(createFile(destination.resolve("c/style.css")), "css file".getBytes(UTF_8));
Files.write(createFile(destination.resolve("my_image.png")), "images".getBytes(UTF_8));
FilesWithEntryPoint files =
readFilesWithEntryPoint(destination.toFile(), Optional.empty(), root);
assertThat(files.entryPoint().toString())
.isEqualTo(filenameJoiner.join("some", "path", "path.zip"));
assertThat(readAllFiles(files).keySet())
.containsExactly(filenameJoiner.join("some", "path", "path.zip"));
}
/**
* Currently tests the "unimplemented" behavior.
*
* <p>TODO(guyben): switch to checking zip file instead.
*/
@Test
void testCreateReportFiles_multipleFiles_withBadHint() throws Exception {
Path root = toNormalizedPath(createDirectories(tmpDir.resolve("my/root")).toAbsolutePath());
Path destination = createDirectories(root.resolve("some/path"));
// This entry point points to a directory, which isn't an appropriate entry point
File badEntryPoint = createDirectories(destination.resolve("a/b")).toFile();
createDirectory(destination.resolve("c"));
Files.write(createFile(destination.resolve("index.html")), "some data".getBytes(UTF_8));
Files.write(createFile(destination.resolve("a/index.html")), "wrong index".getBytes(UTF_8));
Files.write(createFile(destination.resolve("c/style.css")), "css file".getBytes(UTF_8));
Files.write(createFile(destination.resolve("my_image.png")), "images".getBytes(UTF_8));
FilesWithEntryPoint files =
readFilesWithEntryPoint(destination.toFile(), Optional.of(badEntryPoint), root);
assertThat(files.entryPoint().toString())
.isEqualTo(filenameJoiner.join("some", "path", "path.zip"));
assertThat(readAllFiles(files).keySet())
.containsExactly(filenameJoiner.join("some", "path", "path.zip"));
}
@Test
void testCreateReportFiles_multipleFiles_withGoodHint() throws Exception {
Path root = toNormalizedPath(createDirectories(tmpDir.resolve("my/root")).toAbsolutePath());
Path destination = createDirectories(root.resolve("some/path"));
createDirectories(destination.resolve("a/b"));
createDirectory(destination.resolve("c"));
// The hint is an actual file nested in the destination directory!
Path goodEntryPoint = createFile(destination.resolve("index.html"));
Files.write(goodEntryPoint, "some data".getBytes(UTF_8));
Files.write(createFile(destination.resolve("a/index.html")), "wrong index".getBytes(UTF_8));
Files.write(createFile(destination.resolve("c/style.css")), "css file".getBytes(UTF_8));
Files.write(createFile(destination.resolve("my_image.png")), "images".getBytes(UTF_8));
FilesWithEntryPoint files =
readFilesWithEntryPoint(destination.toFile(), Optional.of(goodEntryPoint.toFile()), root);
assertThat(files.entryPoint().toString())
.isEqualTo(filenameJoiner.join("some", "path", "index.html"));
assertThat(readAllFiles(files))
.containsExactly(
filenameJoiner.join("some", "path", "index.html"), "some data",
filenameJoiner.join("some", "path", "a", "index.html"), "wrong index",
filenameJoiner.join("some", "path", "c", "style.css"), "css file",
filenameJoiner.join("some", "path", "my_image.png"), "images");
}
}

View File

@@ -57,7 +57,7 @@ dependencies {
implementation deps['com.github.ben-manes.caffeine:caffeine']
implementation deps['com.google.code.findbugs:jsr305']
implementation deps['com.google.guava:guava']
implementation deps['javax.inject:javax.inject']
implementation deps['jakarta.inject:jakarta.inject-api']
implementation deps['joda-time:joda-time']
implementation deps['com.google.flogger:flogger']
implementation deps['io.github.java-diff-utils:java-diff-utils']

View File

@@ -12,7 +12,7 @@ com.google.auto:auto-common:1.2.1=annotationProcessor,errorprone,testAnnotationP
com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,checkstyle,compileClasspath,deploy_jar,errorprone,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testing,testingAnnotationProcessor,testingCompileClasspath
com.google.errorprone:error_prone_annotation:2.23.0=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.23.0=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.25.0=compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
com.google.errorprone:error_prone_annotations:2.26.1=compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
com.google.errorprone:error_prone_annotations:2.7.1=checkstyle
com.google.errorprone:error_prone_check_api:2.23.0=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
com.google.errorprone:error_prone_core:2.23.0=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
@@ -24,11 +24,11 @@ com.google.guava:failureaccess:1.0.2=compileClasspath,deploy_jar,runtimeClasspat
com.google.guava:guava-parent:32.1.1-jre=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
com.google.guava:guava:31.0.1-jre=checkstyle
com.google.guava:guava:32.1.1-jre=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
com.google.guava:guava:33.0.0-jre=compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
com.google.guava:guava:33.2.1-jre=compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=checkstyle,compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
com.google.inject:guice:5.1.0=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
com.google.j2objc:j2objc-annotations:1.3=checkstyle
com.google.j2objc:j2objc-annotations:2.8=compileClasspath,testCompileClasspath,testingCompileClasspath
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath,testingCompileClasspath
com.google.protobuf:protobuf-java:3.19.6=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
com.google.truth:truth:1.4.2=compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
com.puppycrawl.tools:checkstyle:9.3=checkstyle
@@ -37,7 +37,8 @@ commons-collections:commons-collections:3.2.2=checkstyle
info.picocli:picocli:4.6.2=checkstyle
io.github.eisop:dataflow-errorprone:3.34.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,compileClasspath,deploy_jar,errorprone,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testing,testingAnnotationProcessor,testingCompileClasspath
javax.inject:javax.inject:1=annotationProcessor,compileClasspath,deploy_jar,errorprone,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testing,testingAnnotationProcessor,testingCompileClasspath
jakarta.inject:jakarta.inject-api:1.0.5=compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor,testingAnnotationProcessor
joda-time:joda-time:2.12.7=compileClasspath,deploy_jar,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
junit:junit:4.13.2=testCompileClasspath,testRuntimeClasspath,testing,testingCompileClasspath
net.sf.saxon:Saxon-HE:10.6=checkstyle
@@ -53,11 +54,11 @@ org.jacoco:org.jacoco.ant:0.8.11=jacocoAnt
org.jacoco:org.jacoco.core:0.8.11=jacocoAnt
org.jacoco:org.jacoco.report:0.8.11=jacocoAnt
org.javassist:javassist:3.28.0-GA=checkstyle
org.junit.jupiter:junit-jupiter-api:5.10.2=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:5.10.2=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:1.10.2=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:1.10.2=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:5.10.2=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:5.11.0-M2=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:5.11.0-M2=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:1.11.0-M2=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:1.11.0-M2=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:5.11.0-M2=testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-commons:9.6=jacocoAnt
org.ow2.asm:asm-tree:9.6=jacocoAnt

View File

@@ -175,14 +175,7 @@ public class TextDiffSubject extends Subject {
.orElse(0);
}
private static class SideBySideRowFormatter {
private final int maxExpectedLineLength;
private final int maxActualLineLength;
private SideBySideRowFormatter(int maxExpectedLineLength, int maxActualLineLength) {
this.maxExpectedLineLength = maxExpectedLineLength;
this.maxActualLineLength = maxActualLineLength;
}
private record SideBySideRowFormatter(int maxExpectedLineLength, int maxActualLineLength) {
public String formatRow(String expected, String actual, char padChar) {
return String.format(

View File

@@ -262,6 +262,9 @@
{
"moduleLicense": "Unicode/ICU License"
},
{
"moduleLicense": "Unicode-3.0"
},
{
"moduleLicense": "Public Domain",
"moduleName": "aopalliance:aopalliance"

View File

@@ -47,7 +47,7 @@ class GradleFlag:
PROPERTIES_HEADER = """\
# This file defines properties used by the gradle build. It must be kept in
# This file defines properties used by the gradle build. It must be kept in
# sync with config/nom_build.py.
#
# To regenerate, run ./nom_build --generate-gradle-properties
@@ -89,13 +89,6 @@ PROPERTIES = [
'Allow connecting to plain HTTP repositories. This is provided '
'to allow us to communicate to a local proxy when doing '
'dependency updates.'),
Property('uploaderDestination',
'Location to upload test reports to. Normally this should be a '
'GCS url (see also uploaderCredentialsFile)'),
Property('uploaderCredentialsFile',
'json credentials file to use to upload test reports.'),
Property('uploaderMultithreadedUpload',
'Whether to enable multithread upload.'),
Property('verboseTestOutput',
'If true, show all test output in near-realtime.',
'false',
@@ -277,8 +270,7 @@ def generate_gradle_properties() -> str:
def get_root() -> str:
"""Returns the root of the nomulus build tree."""
cur_dir = os.getcwd()
if not os.path.exists(os.path.join(cur_dir, 'buildSrc')) or \
not os.path.exists(os.path.join(cur_dir, 'core')) or \
if not os.path.exists(os.path.join(cur_dir, 'core')) or \
not os.path.exists(os.path.join(cur_dir, 'gradle.properties')):
raise Exception('You must run this script from the root directory')
return cur_dir

View File

@@ -100,12 +100,12 @@ PRESUBMITS = {
{"node_modules/"}, REQUIRED):
"Source files must end in a newline.",
# System.(out|err).println should only appear in tools/
# System.(out|err).println should only appear in tools/ or load-testing/
PresubmitCheck(
r".*\bSystem\.(out|err)\.print", "java", {
"StackdriverDashboardBuilder.java", "/tools/", "/example/",
"RegistryTestServerMain.java", "TestServerExtension.java",
"FlowDocumentationTool.java"
"/load-testing/", "RegistryTestServerMain.java",
"TestServerExtension.java", "FlowDocumentationTool.java"
}):
"System.(out|err).println is only allowed in tools/ packages. Please "
"use a logger instead.",
@@ -173,17 +173,41 @@ PRESUBMITS = {
):
"JavaScript files should not include console logging.",
PresubmitCheck(
r"org\.testcontainers\.shaded\.",
r".*\nimport (static )?.*\.shaded\..*",
"java",
{"/node_modules/"},
):
"Do not use shaded dependencies from testcontainers.",
"Do not use shaded dependencies",
PresubmitCheck(
r"com\.google\.common\.truth\.Truth8",
r".*com\.google\.common\.truth\.Truth8.*",
"java",
{"/node_modules/"},
):
"Truth8 is deprecated. Use Truth instead.",
PresubmitCheck(
r".*java\.util\.Date.*",
"java",
{"/node_modules/", "JpaTransactionManagerImpl.java"},
):
"Do not use java.util.Date. Use classes in java.time package instead.",
PresubmitCheck(
r".*com\.google\.api\.client\.http\.HttpStatusCodes.*",
"java",
{"/node_modules/"},
):
"Use status code from jakarta.servlet.http.HttpServletResponse.",
PresubmitCheck(
r".*mock\(Response\.class\).*",
"java",
{"/node_modules/"},
):
"Do not mock Response, use FakeResponse.",
PresubmitCheck(
r".*javax\.servlet\..*",
"java",
{"/node_modules/"},
):
"Do not use javax.servlet.* Use jakarta.servlet.* instead.",
}
# Note that this regex only works for one kind of Flyway file. If we want to

View File

@@ -36,7 +36,11 @@ yarn-error.log
/libpeerconnection.log
testem.log
/typings
.nx/
# System files
.DS_Store
Thumbs.db
# Build artifact
/staged/dist

View File

@@ -15,12 +15,16 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/console-webapp",
"outputPath": {
"base": "staged/dist/",
"browser": ""
},
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
@@ -34,7 +38,8 @@
"stylePreprocessorOptions": {
"includePaths": ["node_modules/"]
},
"scripts": []
"scripts": [],
"browser": "src/main.ts"
},
"configurations": {
"production": {
@@ -59,9 +64,7 @@
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
@@ -73,10 +76,10 @@
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "console-webapp:build:production"
"buildTarget": "console-webapp:build:production"
},
"development": {
"browserTarget": "console-webapp:build:development"
"buildTarget": "console-webapp:build:development"
}
},
"defaultConfiguration": "development"
@@ -84,7 +87,7 @@
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "console-webapp:build"
"buildTarget": "console-webapp:build"
}
},
"test": {
@@ -122,6 +125,9 @@
}
},
"cli": {
"cache": {
"enabled": false
},
"analytics": false,
"schematicCollections": [
"@angular-eslint/schematics"

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -11,12 +11,12 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
def consoleDir = "${rootDir}/console-webapp"
def projectParam = "--project=${rootProject.gcpProject}"
clean {
delete "${consoleDir}/node_modules"
delete "${consoleDir}/dist"
delete "${consoleDir}/staged/dist"
}
task npmInstallDeps(type: Exec) {
@@ -62,8 +62,14 @@ task checkFormatting(type: Exec) {
args 'run', 'prettify:check'
}
tasks.runConsoleWebappUnitTests.dependsOn(tasks.npmInstallDeps)
task deploy(type: Exec) {
workingDir "${consoleDir}/staged"
executable 'gcloud'
args 'app', 'deploy', "${projectParam}", '--quiet'
}
tasks.buildConsoleWebappProd.dependsOn(tasks.npmInstallDeps)
tasks.applyFormatting.dependsOn(tasks.npmInstallDeps)
tasks.checkFormatting.dependsOn(tasks.npmInstallDeps)
tasks.build.dependsOn(tasks.checkFormatting)
tasks.deploy.dependsOn(tasks.buildConsoleWebappProd)

File diff suppressed because it is too large Load Diff

View File

@@ -16,35 +16,35 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^17.0.7",
"@angular/cdk": "^17.0.4",
"@angular/common": "^17.0.7",
"@angular/compiler": "^17.0.7",
"@angular/core": "^17.0.7",
"@angular/forms": "^17.0.7",
"@angular/material": "^17.0.4",
"@angular/platform-browser": "^17.0.7",
"@angular/platform-browser-dynamic": "^17.0.7",
"@angular/router": "^17.0.7",
"@angular/animations": "^18.0.2",
"@angular/cdk": "^18.0.2",
"@angular/common": "^18.0.2",
"@angular/compiler": "^18.0.2",
"@angular/core": "^18.0.2",
"@angular/forms": "^18.0.2",
"@angular/material": "^18.0.2",
"@angular/platform-browser": "^18.0.2",
"@angular/platform-browser-dynamic": "^18.0.2",
"@angular/router": "^18.0.2",
"rxjs": "~7.5.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.0.7",
"@angular-eslint/builder": "17.1.1",
"@angular-eslint/eslint-plugin": "17.1.1",
"@angular-eslint/eslint-plugin-template": "17.1.1",
"@angular-eslint/schematics": "17.1.1",
"@angular-eslint/template-parser": "17.1.1",
"@angular/cli": "~17.0.7",
"@angular/compiler-cli": "^17.0.7",
"@angular-devkit/build-angular": "^18.0.3",
"@angular-eslint/builder": "18.0.1",
"@angular-eslint/eslint-plugin": "18.0.1",
"@angular-eslint/eslint-plugin-template": "18.0.1",
"@angular-eslint/schematics": "18.0.1",
"@angular-eslint/template-parser": "18.0.1",
"@angular/cli": "~18.0.3",
"@angular/compiler-cli": "^18.0.2",
"@types/jasmine": "~4.0.0",
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"concurrently": "^7.6.0",
"eslint": "^8.39.0",
"eslint": "^8.57.0",
"jasmine-core": "~4.3.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.1.0",
@@ -52,6 +52,6 @@
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"prettier": "2.8.7",
"typescript": "~5.2.2"
"typescript": "~5.4.5"
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,69 +13,106 @@
// limitations under the License.
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TldsComponent } from './tlds/tlds.component';
import { HomeComponent } from './home/home.component';
import { SettingsComponent } from './settings/settings.component';
import SettingsContactComponent from './settings/contact/contact.component';
import SettingsWhoisComponent from './settings/whois/whois.component';
import SettingsUsersComponent from './settings/users/users.component';
import SettingsSecurityComponent from './settings/security/security.component';
import { RegistrarGuard } from './registrar/registrar.guard';
import { RegistrarComponent } from './registrar/registrarsTable.component';
import { EmptyRegistrar } from './registrar/emptyRegistrar.component';
import ContactComponent from './settings/contact/contact.component';
import WhoisComponent from './settings/whois/whois.component';
import SecurityComponent from './settings/security/security.component';
import UsersComponent from './settings/users/users.component';
import { Route, RouterModule } from '@angular/router';
import { BillingInfoComponent } from './billingInfo/billingInfo.component';
import { DomainListComponent } from './domains/domainList.component';
import { HomeComponent } from './home/home.component';
import { RegistrarDetailsComponent } from './registrar/registrarDetails.component';
import { RegistrarComponent } from './registrar/registrarsTable.component';
import { ResourcesComponent } from './resources/resources.component';
import ContactComponent from './settings/contact/contact.component';
import SecurityComponent from './settings/security/security.component';
import { SettingsComponent } from './settings/settings.component';
import UsersComponent from './settings/users/users.component';
import WhoisComponent from './settings/whois/whois.component';
import { SupportComponent } from './support/support.component';
const routes: Routes = [
export interface RouteWithIcon extends Route {
iconName?: string;
}
export const routes: RouteWithIcon[] = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'registrars', component: RegistrarComponent },
{ path: 'empty-registrar', component: EmptyRegistrar },
{ path: 'home', component: HomeComponent, canActivate: [RegistrarGuard] },
{ path: 'tlds', component: TldsComponent, canActivate: [RegistrarGuard] },
{
path: 'home',
component: HomeComponent,
title: 'Dashboard',
iconName: 'view_comfy_alt',
},
// { path: 'tlds', component: TldsComponent, title: "TLDs", iconName: "event_list" },
{
path: DomainListComponent.PATH,
component: DomainListComponent,
canActivate: [RegistrarGuard],
title: 'Domains',
iconName: 'view_list',
},
{
path: SettingsComponent.PATH,
component: SettingsComponent,
title: 'Settings',
iconName: 'settings',
children: [
{
path: '',
redirectTo: 'registrars',
redirectTo: ContactComponent.PATH,
pathMatch: 'full',
},
{
path: ContactComponent.PATH,
component: SettingsContactComponent,
canActivate: [RegistrarGuard],
component: ContactComponent,
title: 'Contacts',
},
{
path: WhoisComponent.PATH,
component: SettingsWhoisComponent,
canActivate: [RegistrarGuard],
component: WhoisComponent,
title: 'WHOIS Info',
},
{
path: SecurityComponent.PATH,
component: SettingsSecurityComponent,
canActivate: [RegistrarGuard],
component: SecurityComponent,
title: 'Security',
},
{
path: UsersComponent.PATH,
component: SettingsUsersComponent,
canActivate: [RegistrarGuard],
},
{
path: RegistrarComponent.PATH,
component: RegistrarComponent,
component: UsersComponent,
},
],
},
// {
// path: EppConsole.PATH,
// component: EppConsoleComponent,
// title: "EPP Console",
// iconName: "upgrade"
// },
{
path: RegistrarComponent.PATH,
component: RegistrarComponent,
title: 'Registrars',
iconName: 'account_circle',
},
{
path: RegistrarDetailsComponent.PATH,
component: RegistrarDetailsComponent,
},
{
path: BillingInfoComponent.PATH,
component: BillingInfoComponent,
title: 'Billing Info',
iconName: 'credit_card',
},
{
path: ResourcesComponent.PATH,
component: ResourcesComponent,
title: 'Resources',
iconName: 'description',
},
{
path: SupportComponent.PATH,
component: SupportComponent,
title: 'Support',
iconName: 'help',
},
];
@NgModule({

View File

@@ -1,24 +1,19 @@
<div class="console-app">
<app-header (toggleNavOpen)="sidenav.toggle()"></app-header>
<div class="console-app mat-typography">
<app-header (toggleNavOpen)="toggleSidenav()"></app-header>
<mat-sidenav-container class="console-app__container">
<mat-sidenav #sidenav class="console-app__sidebar">
<mat-nav-list>
<a mat-list-item [routerLink]="'/home'" routerLinkActive="active">
Home page
</a>
<a mat-list-item [routerLink]="'/tlds'" routerLinkActive="active">
TLDS
</a>
<a mat-list-item [routerLink]="'/settings'" routerLinkActive="active">
Settings
</a>
</mat-nav-list>
<mat-sidenav
[mode]="breakpointObserver.isMobileView() ? 'over' : 'side'"
[opened]="!breakpointObserver.isMobileView()"
#sidenav
class="console-app__sidebar"
>
<app-navigation />
</mat-sidenav>
<mat-sidenav-content class="console-app__content-wrapper">
<div *ngIf="globalLoader.isLoading" class="console-app__global-spinner">
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>
<div class="console-app__content" *ngIf="renderRouter">
<div class="console-app__content">
<router-outlet></router-outlet>
</div>
</mat-sidenav-content>

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,8 +13,8 @@
// limitations under the License.
:host {
font-family: Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol" !important;
font-family: "Google Sans", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important;
font-size: 14px;
color: #333;
box-sizing: border-box;
@@ -26,26 +26,18 @@
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
&__container {
flex: 1;
margin-top: -12px;
padding-bottom: 36px;
background: transparent;
}
&__sidebar {
min-width: 300px;
a::before {
background-color: transparent;
}
.active {
background-color: var(--secondary);
}
}
&__content-wrapper {
margin: 12px 24px;
min-width: 240px;
border: 0;
}
&__content {
max-width: 1340px;
margin: 0 auto;
padding: 0 16px;
}
&__global-spinner {
margin-bottom: 2rem;

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,25 +12,25 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialModule } from './material.module';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { BackendService } from './shared/services/backend.service';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
RouterTestingModule,
MaterialModule,
BrowserAnimationsModule,
],
providers: [BackendService],
declarations: [AppComponent],
imports: [RouterTestingModule, MaterialModule, BrowserAnimationsModule],
providers: [
BackendService,
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();
});

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,12 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { AfterViewInit, Component, ViewChild, effect } from '@angular/core';
import { RegistrarService } from './registrar/registrar.service';
import { UserDataService } from './shared/services/userData.service';
import { GlobalLoaderService } from './shared/services/globalLoader.service';
import { NavigationEnd, Router } from '@angular/router';
import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { MatSidenav } from '@angular/material/sidenav';
import { NavigationEnd, Router } from '@angular/router';
import { RegistrarService } from './registrar/registrar.service';
import { BreakPointObserverService } from './shared/services/breakPoint.service';
import { GlobalLoaderService } from './shared/services/globalLoader.service';
import { UserDataService } from './shared/services/userData.service';
@Component({
selector: 'app-root',
@@ -25,32 +26,32 @@ import { MatSidenav } from '@angular/material/sidenav';
styleUrls: ['./app.component.scss'],
})
export class AppComponent implements AfterViewInit {
renderRouter: boolean = true;
@ViewChild('sidenav')
@ViewChild(MatSidenav)
sidenav!: MatSidenav;
constructor(
protected registrarService: RegistrarService,
protected userDataService: UserDataService,
protected globalLoader: GlobalLoaderService,
protected router: Router
) {
effect(() => {
if (registrarService.registrarId()) {
this.renderRouter = false;
setTimeout(() => {
this.renderRouter = true;
}, 400);
}
});
}
protected breakpointObserver: BreakPointObserverService,
private router: Router
) {}
ngAfterViewInit() {
this.router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.sidenav.close();
if (this.breakpointObserver.isMobileView()) {
this.sidenav.close();
}
}
});
}
toggleSidenav() {
if (this.sidenav.opened) {
this.sidenav.close();
} else {
this.sidenav.open();
}
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,85 +13,86 @@
// limitations under the License.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialModule } from './material.module';
import { BackendService } from './shared/services/backend.service';
import { HomeComponent } from './home/home.component';
import { TldsComponent } from './tlds/tlds.component';
import { HeaderComponent } from './header/header.component';
import { SettingsComponent } from './settings/settings.component';
import SettingsContactComponent, {
ContactDetailsDialogComponent,
} from './settings/contact/contact.component';
import { HttpClientModule } from '@angular/common/http';
import { RegistrarComponent } from './registrar/registrarsTable.component';
import { RegistrarGuard } from './registrar/registrar.guard';
import SecurityComponent from './settings/security/security.component';
import { provideHttpClient } from '@angular/common/http';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { EmptyRegistrar } from './registrar/emptyRegistrar.component';
import { RegistrarSelectorComponent } from './registrar/registrarSelector.component';
import { GlobalLoaderService } from './shared/services/globalLoader.service';
import { ContactWidgetComponent } from './home/widgets/contactWidget.component';
import { PromotionsWidgetComponent } from './home/widgets/promotionsWidget.component';
import { TldsWidgetComponent } from './home/widgets/tldsWidget.component';
import { ResourcesWidgetComponent } from './home/widgets/resourcesWidget.component';
import { EppWidgetComponent } from './home/widgets/eppWidget.component';
import { BillingWidgetComponent } from './home/widgets/billingWidget.component';
import { DomainsWidgetComponent } from './home/widgets/domainsWidget.component';
import { SettingsWidgetComponent } from './home/widgets/settingsWidget.component';
import { UserDataService } from './shared/services/userData.service';
import WhoisComponent from './settings/whois/whois.component';
import { SnackBarModule } from './snackbar.module';
import { RegistrarDetailsComponent } from './registrar/registrarDetails.component';
import { BillingInfoComponent } from './billingInfo/billingInfo.component';
import { DomainListComponent } from './domains/domainList.component';
import { DialogBottomSheetWrapper } from './shared/components/dialogBottomSheet.component';
import { HeaderComponent } from './header/header.component';
import { HomeComponent } from './home/home.component';
import { NavigationComponent } from './navigation/navigation.component';
import NewRegistrarComponent from './registrar/newRegistrar.component';
import { RegistrarDetailsComponent } from './registrar/registrarDetails.component';
import { RegistrarSelectorComponent } from './registrar/registrarSelector.component';
import { RegistrarComponent } from './registrar/registrarsTable.component';
import { ResourcesComponent } from './resources/resources.component';
import SettingsContactComponent from './settings/contact/contact.component';
import { ContactDetailsComponent } from './settings/contact/contactDetails.component';
import EppPasswordEditComponent from './settings/security/eppPasswordEdit.component';
import SecurityComponent from './settings/security/security.component';
import SecurityEditComponent from './settings/security/securityEdit.component';
import { SettingsComponent } from './settings/settings.component';
import WhoisComponent from './settings/whois/whois.component';
import WhoisEditComponent from './settings/whois/whoisEdit.component';
import { NotificationsComponent } from './shared/components/notifications/notifications.component';
import { SelectedRegistrarWrapper } from './shared/components/selectedRegistrarWrapper/selectedRegistrarWrapper.component';
import { LocationBackDirective } from './shared/directives/locationBack.directive';
import { BreakPointObserverService } from './shared/services/breakPoint.service';
import { GlobalLoaderService } from './shared/services/globalLoader.service';
import { UserDataService } from './shared/services/userData.service';
import { SnackBarModule } from './snackbar.module';
import { SupportComponent } from './support/support.component';
import { TldsComponent } from './tlds/tlds.component';
@NgModule({
declarations: [
AppComponent,
DialogBottomSheetWrapper,
BillingWidgetComponent,
ContactDetailsDialogComponent,
ContactWidgetComponent,
BillingInfoComponent,
ContactDetailsComponent,
DomainListComponent,
DomainsWidgetComponent,
EmptyRegistrar,
EppWidgetComponent,
EppPasswordEditComponent,
HeaderComponent,
HomeComponent,
PromotionsWidgetComponent,
LocationBackDirective,
NavigationComponent,
NewRegistrarComponent,
NotificationsComponent,
RegistrarComponent,
RegistrarDetailsComponent,
RegistrarSelectorComponent,
ResourcesWidgetComponent,
ResourcesComponent,
SecurityComponent,
SecurityEditComponent,
SelectedRegistrarWrapper,
SettingsComponent,
SettingsContactComponent,
SettingsWidgetComponent,
SupportComponent,
TldsComponent,
TldsWidgetComponent,
WhoisComponent,
WhoisEditComponent,
],
bootstrap: [AppComponent],
imports: [
AppRoutingModule,
BrowserAnimationsModule,
BrowserModule,
FormsModule,
HttpClientModule,
MaterialModule,
SnackBarModule,
],
providers: [
BackendService,
BreakPointObserverService,
GlobalLoaderService,
RegistrarGuard,
UserDataService,
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
@@ -99,7 +100,7 @@ import { DialogBottomSheetWrapper } from './shared/components/dialogBottomSheet.
subscriptSizing: 'dynamic',
},
},
provideHttpClient(),
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,16 @@
<app-selected-registrar-wrapper>
<h1 class="mat-headline-4">Billing Info</h1>
<div class="console-app__billing">
<div>
<div class="console-app__billing-subhead">
Billing records and information
</div>
<a class="text-l" href="{{ driveFolderUrl() }}" target="_blank"
>View on Google Drive</a
>
</div>
<div>
<img src="./assets/billing.png" />
</div>
</div>
</app-selected-registrar-wrapper>

View File

@@ -0,0 +1,22 @@
.console-app__billing {
display: flex;
flex-wrap: wrap-reverse;
> div {
flex: 1;
display: flex;
justify-content: center;
flex-direction: column;
text-align: left;
max-width: 300px;
min-width: 200px;
margin-top: -40px;
}
img {
aspect-ratio: 1 / 1;
width: 100%;
}
&-subhead {
font-size: 20px;
margin-bottom: 20px;
}
}

View File

@@ -0,0 +1,47 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, computed } from '@angular/core';
import { RegistrarService } from '../registrar/registrar.service';
import { MatSnackBar } from '@angular/material/snack-bar';
@Component({
selector: 'app-billingInfo',
templateUrl: './billingInfo.component.html',
styleUrls: ['./billingInfo.component.scss'],
})
export class BillingInfoComponent {
public static PATH = 'billingInfo';
constructor(
public registrarService: RegistrarService,
private _snackBar: MatSnackBar
) {}
driveFolderUrl = computed<string>(() => {
if (this.registrarService.registrar()?.driveFolderId) {
return (
'https://drive.google.com/drive/folders/' +
this.registrarService.registrar()?.driveFolderId
);
}
return '';
});
openBillingDetails(e: MouseEvent) {
if (!this.registrarService.registrar()?.driveFolderId) {
e.preventDefault();
this._snackBar.open('Billing Folder ID has not been assigned');
}
}
}

View File

@@ -1,60 +1,95 @@
<div class="console-domains">
<mat-form-field>
<mat-label>Filter</mat-label>
<input
type="search"
matInput
[(ngModel)]="searchTerm"
(ngModelChange)="sendInput()"
#input
/>
</mat-form-field>
<app-selected-registrar-wrapper>
<div class="console-app-domains">
<h1 class="mat-headline-4">Domains</h1>
<div *ngIf="isLoading; else domains_content" class="console-domains__loading">
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
@if (!isLoading && totalResults == 0) {
<div class="console-app__empty-domains">
<h1>
<mat-icon class="console-app__empty-domains-icon secondary-text"
>apps_outage</mat-icon
>
</h1>
<h1>No domains found</h1>
</div>
} @else {
<div class="console-app__domains-table-parent">
@if (isLoading) {
<div class="console-app__domains-spinner">
<mat-spinner />
</div>
} @else {
<a
mat-stroked-button
color="primary"
href="/console-api/dum-download?registrarId={{
registrarService.registrarId()
}}"
class="console-app-domains__download"
>
<mat-icon>download</mat-icon>
Download domains (.csv)
</a>
<mat-form-field class="console-app__domains-filter">
<mat-label>Filter</mat-label>
<input
type="search"
matInput
[(ngModel)]="searchTerm"
(ngModelChange)="sendInput()"
[disabled]="isLoading"
#input
/>
</mat-form-field>
}
<mat-table
[dataSource]="dataSource"
class="mat-elevation-z0"
class="console-app__domains-table"
>
<ng-container matColumnDef="domainName">
<mat-header-cell *matHeaderCellDef>Domain Name</mat-header-cell>
<mat-cell *matCellDef="let element">{{
element.domainName
}}</mat-cell>
</ng-container>
<ng-container matColumnDef="creationTime">
<mat-header-cell *matHeaderCellDef>Creation Time</mat-header-cell>
<mat-cell *matCellDef="let element">
{{ element.creationTime.creationTime }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="registrationExpirationTime">
<mat-header-cell *matHeaderCellDef>Expiration Time</mat-header-cell>
<mat-cell *matCellDef="let element">
{{ element.registrationExpirationTime }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="statuses">
<mat-header-cell *matHeaderCellDef>Statuses</mat-header-cell>
<mat-cell *matCellDef="let element">{{ element.statuses }}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
<!-- Row shown when there is no matching data. -->
<mat-row *matNoDataRow>
<mat-cell colspan="4">No domains found</mat-cell>
</mat-row>
</mat-table>
<mat-paginator
[length]="totalResults"
[pageIndex]="pageNumber"
[pageSize]="resultsPerPage"
[pageSizeOptions]="[10, 25, 50, 100, 500]"
(page)="onPageChange($event)"
aria-label="Select page of domain results"
showFirstLastButtons
></mat-paginator>
</div>
}
</div>
<ng-template #domains_content>
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="domainName">
<th mat-header-cell *matHeaderCellDef>Domain Name</th>
<td mat-cell *matCellDef="let element">{{ element.domainName }}</td>
</ng-container>
<ng-container matColumnDef="creationTime">
<th mat-header-cell *matHeaderCellDef>Creation Time</th>
<td mat-cell *matCellDef="let element">
{{ element.creationTime.creationTime }}
</td>
</ng-container>
<ng-container matColumnDef="registrationExpirationTime">
<th mat-header-cell *matHeaderCellDef>Expiration Time</th>
<td mat-cell *matCellDef="let element">
{{ element.registrationExpirationTime }}
</td>
</ng-container>
<ng-container matColumnDef="statuses">
<th mat-header-cell *matHeaderCellDef>Statuses</th>
<td mat-cell *matCellDef="let element">{{ element.statuses }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
<!-- Row shown when there is no matching data. -->
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" colspan="4">No domains found</td>
</tr>
</table>
<mat-paginator
[length]="totalResults"
[pageIndex]="pageNumber"
[pageSize]="resultsPerPage"
[pageSizeOptions]="[10, 25, 50, 100, 500]"
(page)="onPageChange($event)"
aria-label="Select page of domain results"
showFirstLastButtons
></mat-paginator>
</ng-template>
</div>
</app-selected-registrar-wrapper>

View File

@@ -0,0 +1,54 @@
.console-app {
$min-width: 756px;
&__empty-domains {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
&-icon {
transform: scale(3);
margin-bottom: 0.5rem;
}
}
&-domains__download {
position: absolute;
top: -55px;
right: 0;
}
&__domains {
width: 100%;
overflow: auto;
}
&__domains-filter {
min-width: $min-width !important;
width: 100%;
}
&__domains-table-parent {
position: relative;
width: fit-content;
min-width: 100%;
}
&__domains-table {
min-width: $min-width !important;
}
&__domains-spinner {
align-items: center;
display: flex;
justify-content: center;
position: absolute;
height: 100%;
width: 100%;
background: rgba(255, 255, 255, 0.6);
}
.mat-mdc-paginator {
min-width: $min-width !important;
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,11 +14,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DomainListComponent } from './domainList.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialModule } from '../material.module';
import { BackendService } from '../shared/services/backend.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { DomainListComponent } from './domainList.component';
describe('DomainListComponent', () => {
let component: DomainListComponent;
@@ -27,12 +28,12 @@ describe('DomainListComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DomainListComponent],
imports: [
HttpClientTestingModule,
MaterialModule,
BrowserAnimationsModule,
imports: [MaterialModule, BrowserAnimationsModule],
providers: [
BackendService,
provideHttpClient(),
provideHttpClientTesting(),
],
providers: [BackendService],
}).compileComponents();
fixture = TestBed.createComponent(DomainListComponent);

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,13 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { BackendService } from '../shared/services/backend.service';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, ViewChild, effect } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { RegistrarService } from '../registrar/registrar.service';
import { Domain, DomainListService } from './domainList.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableDataSource } from '@angular/material/table';
import { Subject, debounceTime } from 'rxjs';
import { RegistrarService } from '../registrar/registrar.service';
import { BackendService } from '../shared/services/backend.service';
import { Domain, DomainListService } from './domainList.service';
@Component({
selector: 'app-domain-list',
@@ -45,15 +47,24 @@ export class DomainListComponent {
pageNumber?: number;
resultsPerPage = 50;
totalResults?: number;
totalResults?: number = 0;
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
constructor(
private backendService: BackendService,
private domainListService: DomainListService,
private registrarService: RegistrarService
) {}
protected registrarService: RegistrarService,
private _snackBar: MatSnackBar
) {
effect(() => {
this.pageNumber = 0;
this.totalResults = 0;
if (this.registrarService.registrarId()) {
this.reloadData();
}
});
}
ngOnInit() {
this.dataSource.paginator = this.paginator;
@@ -63,7 +74,6 @@ export class DomainListComponent {
.subscribe((searchTermValue) => {
this.reloadData();
});
this.reloadData();
}
ngOnDestroy() {
@@ -79,10 +89,16 @@ export class DomainListComponent {
this.totalResults,
this.searchTerm
)
.subscribe((domainListResult) => {
this.dataSource.data = domainListResult.domains;
this.totalResults = domainListResult.totalResults;
this.isLoading = false;
.subscribe({
error: (err: HttpErrorResponse) => {
this._snackBar.open(err.message);
this.isLoading = false;
},
next: (domainListResult) => {
this.dataSource.data = (domainListResult || {}).domains;
this.totalResults = (domainListResult || {}).totalResults || 0;
this.isLoading = false;
},
});
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,9 +13,9 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { BackendService } from '../shared/services/backend.service';
import { RegistrarService } from '../registrar/registrar.service';
import { tap } from 'rxjs';
import { RegistrarService } from '../registrar/registrar.service';
import { BackendService } from '../shared/services/backend.service';
export interface CreateAutoTimestamp {
creationTime: string;
@@ -61,7 +61,7 @@ export class DomainListService {
)
.pipe(
tap((domainListResult: DomainListResult) => {
this.checkpointTime = domainListResult.checkpointTime;
this.checkpointTime = domainListResult?.checkpointTime;
})
);
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -16,25 +16,27 @@
&__logo {
color: inherit;
text-decoration: none;
margin-left: -10px;
}
&__menu-btn {
width: 25px;
height: 25px;
padding: 0;
}
&__header {
margin-top: 0;
margin-bottom: 10px;
@media (max-width: 599px) {
.mat-toolbar {
padding: 0;
}
.console-app__logo {
font-size: 16px;
}
button {
padding-left: 0;
padding-right: 0;
width: 30px;
}
margin-bottom: 15px;
.mat-toolbar {
background: transparent;
margin-bottom: 10px;
}
@media (max-width: 480px) {
}
&-user-icon {
margin-left: 20px;
}
}
}
.spacer {
flex: 1;
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,6 +13,7 @@
// limitations under the License.
import { Component, EventEmitter, Output } from '@angular/core';
import { BreakPointObserverService } from '../shared/services/breakPoint.service';
@Component({
selector: 'app-header',
@@ -22,6 +23,8 @@ import { Component, EventEmitter, Output } from '@angular/core';
export class HeaderComponent {
private isNavOpen = false;
constructor(protected breakpointObserver: BreakPointObserverService) {}
@Output() toggleNavOpen = new EventEmitter<boolean>();
toggleNavPane() {

View File

@@ -1,13 +1,52 @@
<div class="console-app__home">
<h1>Welcome to the Google Registry Console</h1>
<div
class="console-app__home"
[class.console-app__home_tablet]="breakPointObserverService.isTabletView()"
>
<h1 class="mat-headline-4">Dashboard</h1>
<div class="console-app__home-widgets">
<div app-domains-widget class="console-app__widget-wrapper__wide"></div>
<div app-contact-widget class="console-app__widget-wrapper__wide"></div>
<div app-tlds-widget></div>
<div app-promotions-widget class="console-app__widget-wrapper__wide"></div>
<div app-settings-widget class="console-app__widget-wrapper__wide"></div>
<div app-resources-widget></div>
<div app-billing-widget></div>
<div app-epp-widget></div>
<mat-card appearance="outlined">
<mat-card-content>
<h3>
<mat-icon class="secondary-text">view_list</mat-icon>
DUMs
</h3>
<p class="secondary-text">View Domains Under Management</p>
</mat-card-content>
<mat-card-actions>
<button mat-button color="primary" (click)="viewDums()">
View DUMs
</button>
</mat-card-actions>
</mat-card>
<mat-card appearance="outlined">
<mat-card-content>
<h3>
<mat-icon class="secondary-text">settings</mat-icon>
EPP Passwords
</h3>
<p class="secondary-text">View / Update EPP Password</p>
</mat-card-content>
<mat-card-actions>
<button mat-button color="primary" (click)="updateEppPassword()">
Update EPP Password
</button>
</mat-card-actions>
</mat-card>
<mat-card appearance="outlined">
<mat-card-content>
<h3>
<mat-icon class="secondary-text">account_circle</mat-icon>
Registrars
</h3>
<p class="secondary-text">View all registrars</p>
</mat-card-content>
<mat-card-actions>
<button mat-button color="primary" (click)="viewRegistrars()">
Manage Registrars
</button>
</mat-card-actions>
</mat-card>
</div>
</div>

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,25 +13,23 @@
// limitations under the License.
.console-app {
&__home {
margin-top: 1rem;
}
&__home-widgets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-gap: 20px;
grid-auto-flow: dense;
display: flex;
gap: 1rem;
h3 {
padding: 0;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
mat-card {
height: 100%;
h1 {
margin-top: 1rem;
}
flex: 1;
}
}
@media (max-width: 510px) {
.console-app__widget-wrapper__wide {
grid-column: initial;
&__home_tablet {
.console-app__home-widgets {
flex-direction: column;
}
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.

View File

@@ -1,4 +1,4 @@
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,12 +12,38 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewEncapsulation } from '@angular/core';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { DomainListComponent } from '../domains/domainList.component';
import { RegistrarComponent } from '../registrar/registrarsTable.component';
import SecurityComponent from '../settings/security/security.component';
import { SettingsComponent } from '../settings/settings.component';
import { BreakPointObserverService } from '../shared/services/breakPoint.service';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class HomeComponent {}
export class HomeComponent {
constructor(
protected breakPointObserverService: BreakPointObserverService,
private router: Router
) {}
viewRegistrars() {
this.router.navigate([RegistrarComponent.PATH], {
queryParamsHandling: 'merge',
});
}
updateEppPassword() {
this.router.navigate(
[SettingsComponent.PATH + '/' + SecurityComponent.PATH],
{ queryParamsHandling: 'merge' }
);
}
viewDums() {
this.router.navigate([DomainListComponent.PATH], {
queryParamsHandling: 'merge',
});
}
}

View File

@@ -1,22 +0,0 @@
<mat-card>
<mat-card-content>
<div class="console-app__widget">
<a
class="console-app__widget_left"
href="{{ driveFolderUrl }}"
(click)="openBillingDetails($event)"
>
<mat-icon class="console-app__widget-icon">account_balance</mat-icon>
<h1 class="console-app__widget-title">Billing Info</h1>
<h4 class="secondary-text text-center">
<span *ngIf="driveFolderUrl; else noDriveFolderUrl">
View important billing and payments information.
</span>
<ng-template #noDriveFolderUrl>
<span> Your billing folder is pending allocation. </span>
</ng-template>
</h4>
</a>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,29 +0,0 @@
<mat-card>
<mat-card-content>
<div class="console-app__widget">
<div class="console-app__widget_left">
<mat-icon class="console-app__widget-icon">call</mat-icon>
<h1 class="console-app__widget-title">Contact Support</h1>
<h4 class="secondary-text text-center">
Let us know if you have any questions
</h4>
</div>
<div class="console-app__widget_right">
<div class="console-app__widget-section-header">Give us a Call</div>
<p class="secondary-text">
Call {{ userDataService.userData?.productName }} support at
<a href="tel:{{ userDataService.userData?.supportPhoneNumber }}">{{
userDataService.userData?.supportPhoneNumber
}}</a>
</p>
<div class="console-app__widget-section-header">Send us an Email</div>
<p class="secondary-text">
Email {{ userDataService.userData?.productName }} at
<a href="mailto:{{ userDataService.userData?.supportEmail }}">{{
userDataService.userData?.supportEmail
}}</a>
</p>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,30 +0,0 @@
<mat-card>
<mat-card-content>
<div class="console-app__widget">
<div class="console-app__widget_left">
<mat-icon class="console-app__widget-icon">view_list</mat-icon>
<h1 class="console-app__widget-title">Domains</h1>
<h4 class="secondary-text text-center">
Manage domain names and registry lock settings.
</h4>
</div>
<div class="console-app__widget_right">
<button mat-button color="primary" class="console-app__widget-link">
Create a Domain
</button>
<p class="secondary-text">Register a new domain name</p>
<button
mat-button
color="primary"
class="console-app__widget-link"
(click)="openDomainsPage()"
>
View DUMs
</button>
<p class="secondary-text">
Download a csv of all domains under management
</p>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,13 +0,0 @@
<mat-card>
<mat-card-content>
<div class="console-app__widget">
<div class="console-app__widget_left">
<mat-icon class="console-app__widget-icon">computer</mat-icon>
<h1 class="console-app__widget-title">EPP Console</h1>
<h4 class="secondary-text text-center">
Test run and execute EPP commands using XML files.
</h4>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,27 +0,0 @@
<mat-card class="console-app__widget-wrapper__wide">
<mat-card-content>
<div class="console-app__widget">
<div class="console-app__widget_left">
<mat-icon class="console-app__widget-icon">subject</mat-icon>
<h1 class="console-app__widget-title">Promotions</h1>
<h4 class="secondary-text text-center">
Manage Google Registry promotions and view onboarding process.
</h4>
</div>
<div class="console-app__widget_right">
<button mat-button color="primary" class="console-app__widget-link">
Manage Preferred Partner Program
</button>
<p class="secondary-text">
Onboard or view current preferred partner status
</p>
<button mat-button color="primary" class="console-app__widget-link">
Manage Registry Lock Program
</button>
<p class="secondary-text">
Onboard or view current registry lock status
</p>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,17 +0,0 @@
<mat-card>
<mat-card-content>
<div class="console-app__widget">
<a
class="console-app__widget_left"
href="{{ userDataService.userData?.technicalDocsUrl }}"
target="_blank"
>
<mat-icon class="console-app__widget-icon">menu_book</mat-icon>
<h1 class="console-app__widget-title">Resources</h1>
<h4 class="secondary-text text-center">
Use Google Drive to view onboarding FAQs, and technical documentation.
</h4>
</a>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,24 +0,0 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { UserDataService } from 'src/app/shared/services/userData.service';
@Component({
selector: '[app-resources-widget]',
templateUrl: './resourcesWidget.component.html',
})
export class ResourcesWidgetComponent {
constructor(public userDataService: UserDataService) {}
}

View File

@@ -1,53 +0,0 @@
<mat-card class="console-app__widget-wrapper__wide">
<mat-card-content>
<div class="console-app__widget">
<div class="console-app__widget_left">
<mat-icon class="console-app__widget-icon">settings</mat-icon>
<h1 class="console-app__widget-title">Settings</h1>
<h4 class="secondary-text text-center">
Configure registrar settings, manage console users, and view activity
log.
</h4>
</div>
<div class="console-app__widget_right">
<button
mat-button
color="primary"
class="console-app__widget-link"
(click)="openContactsPage()"
>
Contact Information
</button>
<p class="secondary-text">Manage Primary, Technical, etc contacts.</p>
<button
mat-button
color="primary"
class="console-app__widget-link"
(click)="openSecurityPage()"
>
Security
</button>
<p class="secondary-text">
Manage IP allow lists and SSL certificates.
</p>
<button mat-button color="primary" class="console-app__widget-link">
Nomulus Password
</button>
<p class="secondary-text">Reset your Nomulus password.</p>
<button mat-button color="primary" class="console-app__widget-link">
User Management
</button>
<p class="secondary-text">Create and manage console user accounts</p>
<button
mat-button
color="primary"
class="console-app__widget-link"
(click)="openRegistrarsPage()"
>
Registrar Management
</button>
<p class="secondary-text">Create and manage registrar accounts</p>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,44 +0,0 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { RegistrarComponent } from 'src/app/registrar/registrarsTable.component';
import ContactComponent from 'src/app/settings/contact/contact.component';
import SecurityComponent from 'src/app/settings/security/security.component';
import { SettingsComponent } from 'src/app/settings/settings.component';
@Component({
selector: '[app-settings-widget]',
templateUrl: './settingsWidget.component.html',
})
export class SettingsWidgetComponent {
constructor(private router: Router) {}
openRegistrarsPage() {
this.navigate(RegistrarComponent.PATH);
}
openSecurityPage() {
this.navigate(SecurityComponent.PATH);
}
openContactsPage() {
this.navigate(ContactComponent.PATH);
}
private navigate(route: string) {
this.router.navigate([SettingsComponent.PATH, route]);
}
}

View File

@@ -1,13 +0,0 @@
<mat-card>
<mat-card-content>
<div class="console-app__widget">
<div class="console-app__widget_left">
<mat-icon class="console-app__widget-icon">list_alt</mat-icon>
<h1 class="console-app__widget-title">TLDs</h1>
<h4 class="secondary-text text-center">
View launch phase information for all onboarded and available TLDs.
</h4>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,16 +12,23 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
import { DialogModule } from '@angular/cdk/dialog';
import { CdkMenuModule } from '@angular/cdk/menu';
import { OverlayModule } from '@angular/cdk/overlay';
import { CdkTableModule } from '@angular/cdk/table';
import { CdkTreeModule } from '@angular/cdk/tree';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatBadgeModule } from '@angular/material/badge';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatNativeDateModule, MatRippleModule } from '@angular/material/core';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatGridListModule } from '@angular/material/grid-list';
@@ -29,23 +36,18 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatNativeDateModule, MatRippleModule } from '@angular/material/core';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTreeModule } from '@angular/material/tree';
import { OverlayModule } from '@angular/cdk/overlay';
import { CdkMenuModule } from '@angular/cdk/menu';
import { DialogModule } from '@angular/cdk/dialog';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatChipsModule } from '@angular/material/chips';
@NgModule({
exports: [
@@ -83,6 +85,8 @@ import { MatChipsModule } from '@angular/material/chips';
MatSnackBarModule,
MatPaginatorModule,
MatChipsModule,
MatAutocompleteModule,
ReactiveFormsModule,
],
})
export class MaterialModule {}

View File

@@ -0,0 +1,44 @@
<mat-tree
[dataSource]="dataSource"
[treeControl]="treeControl"
class="console-app__nav-tree"
>
<mat-tree-node
*matTreeNodeDef="let node"
matTreeNodeToggle
(click)="onClick(node)"
[class.active]="router.url.includes(node.path)"
>
<mat-icon class="console-app__nav-icon" *ngIf="node.iconName">
{{ node.iconName }}
</mat-icon>
{{ node.title }}
</mat-tree-node>
<mat-nested-tree-node
*matTreeNodeDef="let node; when: hasChild"
(click)="onClick(node)"
>
<div class="mat-tree-node" [class.active]="router.url.includes(node.path)">
<button
class="console-app__nav-icon_expand"
mat-icon-button
matTreeNodeToggle
[attr.aria-label]="'Toggle ' + node.title"
>
<mat-icon>
{{ treeControl.isExpanded(node) ? "expand_more" : "chevron_right" }}
</mat-icon>
</button>
<mat-icon class="console-app__nav-icon" *ngIf="node.iconName">
{{ node.iconName }}
</mat-icon>
{{ node.title }}
</div>
<div
[class.console-app__nav-tree_invisible]="!treeControl.isExpanded(node)"
role="group"
>
<ng-container matTreeNodeOutlet></ng-container>
</div>
</mat-nested-tree-node>
</mat-tree>

View File

@@ -0,0 +1,71 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
$expand-icon-size: 26px;
.console-app {
&__sidebar {
min-width: 300px;
border: 0;
}
&__nav-icon {
width: $expand-icon-size;
height: $expand-icon-size;
color: var(--secondary) !important;
margin-right: $expand-icon-size;
&_expand {
position: absolute;
left: 0;
margin: auto;
padding: 0px;
width: $expand-icon-size;
height: $expand-icon-size;
}
}
&__nav-tree {
.mat-tree-node {
cursor: pointer;
padding-left: $expand-icon-size;
position: relative;
&:hover {
background-color: var(--light-highlight);
border-radius: 0 25px 25px 0;
}
&.active {
border-radius: 0 25px 25px 0;
background-color: var(--lightest);
}
}
div[role="group"] > .mat-tree-node {
// expand icon + regular icon + spacing = 3 * $expand-icon-size
padding-left: calc($expand-icon-size * 3);
}
ul,
li {
margin-top: 0;
margin-bottom: 0;
list-style-type: none;
}
&_invisible {
display: none;
}
}
}

View File

@@ -0,0 +1,107 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NestedTreeControl } from '@angular/cdk/tree';
import { Component } from '@angular/core';
import { MatTreeNestedDataSource } from '@angular/material/tree';
import { NavigationEnd, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { RouteWithIcon, routes } from '../app-routing.module';
interface NavMenuNode extends RouteWithIcon {
parentRoute?: RouteWithIcon;
}
/**
* This component is responsible for rendering navigation menu based on the allowed routes
* and keeping UI in sync when route changes (eg highlights selected route in the menu).
*/
@Component({
selector: 'app-navigation',
templateUrl: './navigation.component.html',
styleUrls: ['./navigation.component.scss'],
})
export class NavigationComponent {
renderRouter: boolean = true;
treeControl = new NestedTreeControl<RouteWithIcon>((node) => node.children);
dataSource = new MatTreeNestedDataSource<RouteWithIcon>();
private subscription!: Subscription;
hasChild = (_: number, node: RouteWithIcon) =>
!!node.children && node.children.length > 0;
constructor(protected router: Router) {
this.dataSource.data = this.ngRoutesToNavMenuNodes(routes);
}
ngOnInit() {
this.subscription = this.router.events.subscribe((navigationParams) => {
if (navigationParams instanceof NavigationEnd) {
this.syncExpandedNavigationWithRoute(navigationParams.url);
}
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
syncExpandedNavigationWithRoute(url: string) {
const maybeComponentWithChildren = this.dataSource.data.find((menuNode) => {
return (
// @ts-ignore - optional function added to components with children,
// there's no availble tools to get current active router component
typeof menuNode.component?.matchesUrl === 'function' &&
// @ts-ignore
menuNode.component?.matchesUrl(url)
);
});
if (maybeComponentWithChildren) {
this.treeControl.expand(maybeComponentWithChildren);
}
}
onClick(node: NavMenuNode) {
if (node.parentRoute) {
this.router.navigate([node.parentRoute.path + '/' + node.path], {
queryParamsHandling: 'merge',
});
} else {
this.router.navigate([node.path], { queryParamsHandling: 'merge' });
}
}
/**
* We only want to use routes with titles and we want to provide easy reference to parent node
*/
ngRoutesToNavMenuNodes(routes: RouteWithIcon[]): NavMenuNode[] {
return routes
.filter((r) => r.title)
.map((r) => {
if (r.children) {
return {
...r,
children: r.children
.filter((r) => r.title)
.map((childRoute) => {
return {
...childRoute,
parentRoute: r,
};
}),
};
}
return r;
});
}
}

View File

@@ -1,7 +0,0 @@
<div class="console-app__emty-registrar">
<h1>
<mat-icon class="console-app__emty-registrar-icon">block</mat-icon>
</h1>
<h1>No registrar selected</h1>
<h4 class="mat-body-2">Please select a registrar</h4>
</div>

View File

@@ -1,37 +0,0 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, effect } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { RegistrarService } from './registrar.service';
@Component({
selector: 'app-empty-registrar',
templateUrl: './emptyRegistrar.component.html',
styleUrls: ['./emptyRegistrar.component.scss'],
})
export class EmptyRegistrar {
constructor(
private route: ActivatedRoute,
protected registrarService: RegistrarService,
private router: Router
) {
effect(() => {
if (registrarService.registrarId()) {
this.router.navigate([this.route.snapshot.paramMap.get('nextUrl')]);
}
});
}
}

View File

@@ -0,0 +1,182 @@
<div class="console-new-registrar">
<button
mat-icon-button
aria-label="Back to registrars list"
(click)="goBack()"
>
<mat-icon>arrow_back</mat-icon>
</button>
<div class="spacer"></div>
<h1>Create a registrar</h1>
<form (ngSubmit)="save($event)" #form>
<h2>General</h2>
<section>
<mat-form-field appearance="outline">
<mat-label>Registrar Name: </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="newRegistrar.registrarName"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>Registrar ID: </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="newRegistrar.registrarId"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>Registrar email address: </mat-label>
<input
matInput
type="email"
[required]="true"
[(ngModel)]="newRegistrar.emailAddress"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>Billing Accounts: </mat-label>
<textarea
matInput
required="true"
placeholder="USD=billing-id-for-usd
JPY=billing-id-for-yen"
[ngModel]="billingAccountMap"
(ngModelChange)="onBillingAccountMapChange($event)"
[ngModelOptions]="{ standalone: true }"
></textarea>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>IANA ID: </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="newRegistrar.ianaIdentifier"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>ICANN referral email: </mat-label>
<input
matInput
[required]="true"
type="email"
[(ngModel)]="newRegistrar.icannReferralEmail"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>Drive ID: </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="newRegistrar.driveFolderId"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<h2>Contact Info</h2>
<section>
<mat-form-field appearance="outline">
<mat-label>Street address (Line 1): </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="localizedAddressStreet.line1"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>Street address (Line 2)</mat-label>
<input
matInput
[required]="false"
[(ngModel)]="localizedAddressStreet.line2"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>Street address (Line 3)</mat-label>
<input
matInput
[required]="false"
[(ngModel)]="localizedAddressStreet.line3"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>City: </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="newRegistrar.localizedAddress.city"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>State/Region: </mat-label>
<input
matInput
[required]="false"
[(ngModel)]="newRegistrar.localizedAddress.state"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>ZIP/Postal Code: </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="newRegistrar.localizedAddress.zip"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<section>
<mat-form-field appearance="outline">
<mat-label>Country Code (e.g. US): </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="newRegistrar.localizedAddress.countryCode"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</section>
<button
class="console-new-registrar__submit"
mat-flat-button
color="primary"
type="submit"
>
Save
</button>
</form>
</div>

View File

@@ -0,0 +1,20 @@
.console-new-registrar {
max-width: 616px;
h2 {
margin: 40px 0 25px 0 !important;
}
section {
margin-bottom: 20px;
}
mat-form-field {
display: block;
width: 100%;
}
&__submit {
margin: 30px 0;
}
}

View File

@@ -0,0 +1,99 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { HttpErrorResponse } from '@angular/common/http';
import {
Component,
ElementRef,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Registrar, RegistrarService } from './registrar.service';
interface LocalizedAddressStreet {
line1: string;
line2: string;
line3: string;
}
@Component({
selector: 'app-new-registrar',
templateUrl: './newRegistrar.component.html',
styleUrls: ['./newRegistrar.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export default class NewRegistrarComponent {
protected newRegistrar: Registrar;
protected localizedAddressStreet: LocalizedAddressStreet;
protected billingAccountMap: String = '';
@ViewChild('form') form!: ElementRef;
constructor(
private registrarService: RegistrarService,
private _snackBar: MatSnackBar
) {
this.newRegistrar = {
registrarId: '',
url: '',
whoisServer: '',
registrarName: '',
icannReferralEmail: '',
localizedAddress: {
city: '',
state: '',
zip: '',
countryCode: '',
},
};
this.localizedAddressStreet = {
line1: '',
line2: '',
line3: '',
};
}
onBillingAccountMapChange(val: String) {
const billingAccountMap: { [key: string]: string } = {};
this.newRegistrar.billingAccountMap = val.split('\n').reduce((acc, val) => {
const [currency, billingCode] = val.split('=');
acc[currency] = billingCode;
return acc;
}, billingAccountMap);
}
save(e: SubmitEvent) {
e.preventDefault();
if (this.form.nativeElement.checkValidity()) {
const { line1, line2, line3 } = this.localizedAddressStreet;
this.newRegistrar.localizedAddress.street = [line1, line2, line3].filter(
(v) => !!v
);
this.registrarService.createRegistrar(this.newRegistrar).subscribe({
complete: () => {
this.goBack();
},
error: (err: HttpErrorResponse) => {
this._snackBar.open(err.error);
},
});
} else {
this.form.nativeElement.reportValidity();
}
}
goBack() {
this.registrarService.inNewRegistrarMode.set(false);
}
}

View File

@@ -1,68 +0,0 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { TestBed } from '@angular/core/testing';
import { RegistrarGuard } from './registrar.guard';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import { RegistrarService } from './registrar.service';
describe('RegistrarGuard', () => {
let guard: RegistrarGuard;
let dummyRegistrarService: RegistrarService;
let routeSpy: Router;
let dummyRoute: RouterStateSnapshot;
beforeEach(() => {
routeSpy = jasmine.createSpyObj<Router>('Router', ['navigate']);
dummyRegistrarService = { activeRegistrarId: '' } as RegistrarService;
dummyRoute = { url: '/value' } as RouterStateSnapshot;
TestBed.configureTestingModule({
providers: [
RegistrarGuard,
{ provide: Router, useValue: routeSpy },
{ provide: RegistrarService, useValue: dummyRegistrarService },
],
});
});
it('should not be able to activate when activeRegistrarId is empty', () => {
guard = TestBed.inject(RegistrarGuard);
const res = guard.canActivate(new ActivatedRouteSnapshot(), dummyRoute);
expect(res).toBeFalsy();
});
it('should be able to activate when activeRegistrarId is not empty', () => {
TestBed.overrideProvider(RegistrarService, {
useValue: { activeRegistrarId: 'value' },
});
guard = TestBed.inject(RegistrarGuard);
const res = guard.canActivate(new ActivatedRouteSnapshot(), dummyRoute);
expect(res).toBeTrue();
});
it('should navigate to empty-registrar screen when activeRegistrarId is empty', () => {
guard = TestBed.inject(RegistrarGuard);
guard.canActivate(new ActivatedRouteSnapshot(), dummyRoute);
expect(routeSpy.navigate).toHaveBeenCalledOnceWith([
'/empty-registrar',
{ nextUrl: dummyRoute.url },
]);
});
});

View File

@@ -1,45 +0,0 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import { RegistrarService } from './registrar.service';
@Injectable({
providedIn: 'root',
})
export class RegistrarGuard {
constructor(
private router: Router,
private registrarService: RegistrarService
) {}
canActivate(
_: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Promise<boolean> | boolean {
if (this.registrarService.registrarId()) {
return true;
}
return this.router.navigate([
`/empty-registrar`,
{ nextUrl: state.url || '' },
]);
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,18 +14,24 @@
import { TestBed } from '@angular/core/testing';
import { RegistrarService } from './registrar.service';
import { BackendService } from '../shared/services/backend.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { BackendService } from '../shared/services/backend.service';
import { RegistrarService } from './registrar.service';
describe('RegistrarService', () => {
let service: RegistrarService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [BackendService, MatSnackBar],
imports: [],
providers: [
BackendService,
MatSnackBar,
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(RegistrarService);
});

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,64 +13,106 @@
// limitations under the License.
import { Injectable, computed, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { Observable, switchMap, tap } from 'rxjs';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { BackendService } from '../shared/services/backend.service';
import {
GlobalLoader,
GlobalLoaderService,
} from '../shared/services/globalLoader.service';
import { MatSnackBar } from '@angular/material/snack-bar';
export interface Address {
street?: string[];
city?: string;
countryCode?: string;
zip?: string;
state?: string;
export interface IpAllowListItem {
value: string;
}
export interface Registrar {
export interface Address {
city?: string;
countryCode?: string;
state?: string;
street?: string[];
zip?: string;
}
export interface SecuritySettingsBackendModel {
clientCertificate?: string;
failoverClientCertificate?: string;
ipAddressAllowList?: Array<string>;
// TODO: @ptkach At some point we want to add a back-end support for this
eppPasswordLastUpdated?: string;
}
export interface SecuritySettings
extends Omit<SecuritySettingsBackendModel, 'ipAddressAllowList'> {
ipAddressAllowList?: Array<IpAllowListItem>;
}
export interface WhoisRegistrarFields {
ianaIdentifier?: number;
icannReferralEmail: string;
localizedAddress: Address;
registrarId: string;
url: string;
whoisServer: string;
}
export interface Registrar
extends WhoisRegistrarFields,
SecuritySettingsBackendModel {
allowedTlds?: string[];
billingAccountMap?: object;
driveFolderId?: string;
emailAddress?: string;
faxNumber?: string;
ianaIdentifier?: number;
icannReferralEmail?: string;
ipAddressAllowList?: string[];
localizedAddress?: Address;
phoneNumber?: string;
registrarId: string;
registrarName: string;
registryLockAllowed?: boolean;
url?: string;
whoisServer?: string;
}
@Injectable({
providedIn: 'root',
})
export class RegistrarService implements GlobalLoader {
registrarId = signal<string>('');
registrarId = signal<string>(
new URLSearchParams(document.location.hash.split('?')[1]).get(
'registrarId'
) || ''
);
registrars = signal<Registrar[]>([]);
registrar = computed<Registrar | undefined>(() =>
this.registrars().find((r) => r.registrarId === this.registrarId())
);
inNewRegistrarMode = signal(false);
registrarsLoaded: Promise<void>;
constructor(
private backend: BackendService,
private globalLoader: GlobalLoaderService,
private _snackBar: MatSnackBar
private _snackBar: MatSnackBar,
private router: Router
) {
this.loadRegistrars().subscribe((r) => {
this.globalLoader.stopGlobalLoader(this);
this.registrarsLoaded = new Promise((resolve) => {
this.loadRegistrars().subscribe((r) => {
this.globalLoader.stopGlobalLoader(this);
resolve();
});
});
this.globalLoader.startGlobalLoader(this);
}
public updateSelectedRegistrar(registrarId: string) {
this.registrarId.set(registrarId);
if (registrarId !== this.registrarId()) {
this.registrarId.set(registrarId);
// add registrarId to url query params, so that we can pick it up after page refresh
this.router.navigate([], {
queryParams: { registrarId },
queryParamsHandling: 'merge',
});
}
}
public loadRegistrars(): Observable<Registrar[]> {
@@ -83,6 +125,27 @@ export class RegistrarService implements GlobalLoader {
);
}
createRegistrar(registrar: Registrar) {
return this.backend
.createRegistrar(registrar)
.pipe(switchMap((_) => this.loadRegistrars()));
}
updateRegistrar(updatedRegistrar: Registrar) {
return this.backend.updateRegistrar(updatedRegistrar).pipe(
tap(() => {
this.registrars.set(
this.registrars().map((r) => {
if (r.registrarId === updatedRegistrar.registrarId) {
return updatedRegistrar;
}
return r;
})
);
})
);
}
loadingTimeout() {
this._snackBar.open('Timeout loading registrars');
}

View File

@@ -1,40 +1,99 @@
<div class="registrarDetails" *ngIf="registrarInEdit">
<h3 mat-dialog-title>Edit Registrar: {{ registrarInEdit.registrarId }}</h3>
<div mat-dialog-content>
<div class="console-app__registrar-view">
<h1 class="mat-headline-4">Registrars</h1>
<mat-divider></mat-divider>
<div class="console-app__registrar-view-content">
<div class="console-app__registrar-view-controls">
<button mat-icon-button aria-label="Back to registrars list" backButton>
<mat-icon>arrow_back</mat-icon>
</button>
<div class="spacer"></div>
@if(!inEdit && !registrarNotFound) {
<button
mat-flat-button
color="primary"
aria-label="Edit Registrar"
(click)="inEdit = true"
>
<mat-icon>edit</mat-icon>
Edit
</button>
<button mat-icon-button aria-label="Delete Registrar">
<mat-icon>delete</mat-icon>
</button>
}
</div>
@if(registrarNotFound) {
<h1>Registrar not found</h1>
} @else {
<h1>{{ registrarInEdit.registrarId }}</h1>
<h2 *ngIf="registrarInEdit.registrarName !== registrarInEdit.registrarId">
{{ registrarInEdit.registrarName }}
</h2>
@if(inEdit) {
<form (ngSubmit)="saveAndClose()">
<mat-form-field class="registrarDetails__input">
<mat-label>Registry Lock:</mat-label>
<mat-select
[(ngModel)]="registrarInEdit.registryLockAllowed"
name="registryLockAllowed"
>
<mat-option [value]="true">True</mat-option>
<mat-option [value]="false">False</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="registrarDetails__input">
<mat-label>Onboarded TLDs: </mat-label>
<mat-chip-grid #chipGrid aria-label="Enter TLD">
<mat-chip-row
*ngFor="let tld of registrarInEdit.allowedTlds"
(removed)="removeTLD(tld)"
<div>
<mat-form-field appearance="outline">
<mat-label>Registry Lock:</mat-label>
<mat-select
[(ngModel)]="registrarInEdit.registryLockAllowed"
name="registryLockAllowed"
>
{{ tld }}
<button matChipRemove aria-label="'remove ' + tld">
<mat-icon>cancel</mat-icon>
</button>
</mat-chip-row>
</mat-chip-grid>
<input
placeholder="New tld..."
[matChipInputFor]="chipGrid"
(matChipInputTokenEnd)="addTLD($event)"
/>
</mat-form-field>
<mat-dialog-actions>
<button mat-button (click)="this.params?.close()">Cancel</button>
<button type="submit" mat-button color="primary">Save</button>
</mat-dialog-actions>
<mat-option [value]="true">True</mat-option>
<mat-option [value]="false">False</mat-option>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline">
<mat-label>Onboarded TLDs: </mat-label>
<mat-chip-grid #chipGrid aria-label="Enter TLD">
<mat-chip-row
*ngFor="let tld of registrarInEdit.allowedTlds"
(removed)="removeTLD(tld)"
>
{{ tld }}
<button matChipRemove aria-label="'remove ' + tld">
<mat-icon>cancel</mat-icon>
</button>
</mat-chip-row>
</mat-chip-grid>
<input
placeholder="New tld..."
[matChipInputFor]="chipGrid"
(matChipInputTokenEnd)="addTLD($event)"
/>
</mat-form-field>
</div>
<button
mat-flat-button
color="primary"
aria-label="Edit Registrar"
type="submit"
>
Save
</button>
</form>
} @else {
<mat-card appearance="outlined">
<mat-card-content>
<mat-list role="list">
<mat-list-item role="listitem">
<h2>Registrar details</h2>
</mat-list-item>
<mat-divider></mat-divider>
@for (column of columns; track column.columnDef) {
<mat-list-item role="listitem">
<span class="console-app__list-key">{{ column.header }} </span>
<span
class="console-app__list-value"
[innerHTML]="column.cell(registrarInEdit).replace('<br/>', ' ')"
></span>
</mat-list-item>
<mat-divider></mat-divider>
}
</mat-list>
</mat-card-content>
</mat-card>
} }
</div>
</div>

View File

@@ -1,8 +1,19 @@
.registrarDetails {
min-width: 30vw;
&__input {
display: block;
margin-top: 0.5rem;
.console-app {
&__registrar-view {
&-controls {
display: flex;
align-items: center;
margin: 20px 0;
}
}
&__registrar-view-content {
max-width: 616px;
mat-divider:last-child {
display: none;
}
mat-form-field {
margin-bottom: 20px;
width: 100%;
}
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,45 +12,62 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { Registrar, RegistrarService } from './registrar.service';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { MatChipInputEvent } from '@angular/material/chips';
import { DialogBottomSheetContent } from '../shared/components/dialogBottomSheet.component';
type RegistrarDetailsParams = {
close: Function;
data: {
registrar: Registrar;
};
};
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { Registrar, RegistrarService } from './registrar.service';
import { RegistrarComponent, columns } from './registrarsTable.component';
@Component({
selector: 'app-registrar-details',
templateUrl: './registrarDetails.component.html',
styleUrls: ['./registrarDetails.component.scss'],
})
export class RegistrarDetailsComponent implements DialogBottomSheetContent {
export class RegistrarDetailsComponent implements OnInit {
public static PATH = 'registrars/:id';
inEdit: boolean = false;
registrarInEdit!: Registrar;
params?: RegistrarDetailsParams;
registrarNotFound: boolean = false;
columns = columns.filter((c) => !c.hiddenOnDetailsCard);
private subscription!: Subscription;
constructor(protected registrarService: RegistrarService) {}
constructor(
protected registrarService: RegistrarService,
private route: ActivatedRoute,
private _snackBar: MatSnackBar,
private router: Router
) {}
init(params: RegistrarDetailsParams) {
this.params = params;
this.registrarInEdit = JSON.parse(
JSON.stringify(this.params.data.registrar)
);
}
saveAndClose() {
this.params?.close();
ngOnInit(): void {
this.registrarService.registrarsLoaded.then(() => {
this.subscription = this.route.paramMap.subscribe((params: ParamMap) => {
this.registrarInEdit = structuredClone(
this.registrarService
.registrars()
.filter((r) => r.registrarId === params.get('id'))[0]
);
if (!this.registrarInEdit) {
this._snackBar.open(
`Registrar with id ${params.get('id')} is not available`
);
this.registrarNotFound = true;
} else {
this.registrarNotFound = false;
}
});
});
}
addTLD(e: MatChipInputEvent) {
this.registrarInEdit.allowedTlds = this.registrarInEdit.allowedTlds || [];
this.removeTLD(e.value); // Prevent dups
this.registrarInEdit.allowedTlds = this.registrarInEdit.allowedTlds?.concat(
[e.value.toLowerCase()]
);
this.registrarInEdit.allowedTlds = [
...this.registrarInEdit.allowedTlds,
e.value.toLowerCase(),
];
}
removeTLD(tld: string) {
@@ -58,4 +75,22 @@ export class RegistrarDetailsComponent implements DialogBottomSheetContent {
(v) => v != tld
);
}
saveAndClose() {
this.registrarService.updateRegistrar(this.registrarInEdit).subscribe({
complete: () => {
this.router.navigate([RegistrarComponent.PATH], {
queryParamsHandling: 'merge',
});
},
error: (err: HttpErrorResponse) => {
this._snackBar.open(err.error);
},
});
this.inEdit = false;
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}

View File

@@ -1,29 +1,29 @@
<div class="console-app__registrar">
<button
mat-button
[routerLink]="'/settings/registrars'"
routerLinkActive="active"
*ngIf="isMobile; else desktop"
>
{{ registrarService.registrarId() || "Select registrar" }}
<mat-icon>open_in_new</mat-icon>
</button>
<ng-template #desktop>
<mat-form-field class="mat-form-field-density-5" appearance="fill">
<mat-label>Registrar</mat-label>
<mat-select
[ngModel]="registrarService.registrarId()"
(selectionChange)="
registrarService.updateSelectedRegistrar($event.value)
"
<mat-form-field class="example-full-width" appearance="outline">
<mat-label>Registrar</mat-label>
<input
type="text"
placeholder=""
aria-label="Select Registrar"
matInput
[ngModel]="registrarInput()"
(ngModelChange)="registrarInput.set($event)"
[ngModelOptions]="{ standalone: true }"
(focus)="onFocus()"
[matAutocomplete]="auto"
/>
<mat-autocomplete
autoActiveFirstOption
#auto="matAutocomplete"
(optionSelected)="onSelect($event.option.value)"
>
@for (registrarId of filteredOptions; track registrarId) {
<mat-option
[value]="registrarId"
selected="registrarId === registrarService.registrarId()"
>{{ registrarId }}</mat-option
>
<mat-option
*ngFor="let registrar of registrarService.registrars()"
[value]="registrar.registrarId"
>
{{ registrar.registrarId }}
</mat-option>
</mat-select>
</mat-form-field>
</ng-template>
}
</mat-autocomplete>
</mat-form-field>
</div>

View File

@@ -3,16 +3,5 @@
display: flex;
justify-content: center;
align-items: baseline;
// Fix for angular v15 label issue
// https://github.com/angular/components/issues/26579
.mat-mdc-floating-label {
display: inline !important;
}
.mat-mdc-select-arrow-wrapper {
transform: translateY(0) !important;
}
}
&__title {
margin-right: 1rem;
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,11 +14,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistrarSelectorComponent } from './registrarSelector.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialModule } from '../material.module';
import { BackendService } from '../shared/services/backend.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RegistrarSelectorComponent } from './registrarSelector.component';
describe('RegistrarSelectorComponent', () => {
let component: RegistrarSelectorComponent;
@@ -26,13 +27,13 @@ describe('RegistrarSelectorComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
MaterialModule,
BrowserAnimationsModule,
],
providers: [BackendService],
declarations: [RegistrarSelectorComponent],
imports: [MaterialModule, BrowserAnimationsModule],
providers: [
BackendService,
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();
fixture = TestBed.createComponent(RegistrarSelectorComponent);

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,35 +12,36 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { Component, computed, effect, signal } from '@angular/core';
import { RegistrarService } from './registrar.service';
import { BreakpointObserver } from '@angular/cdk/layout';
import { distinctUntilChanged } from 'rxjs';
const MOBILE_LAYOUT_BREAKPOINT = '(max-width: 599px)';
@Component({
selector: 'app-registrar-selector',
templateUrl: './registrarSelector.component.html',
styleUrls: ['./registrarSelector.component.scss'],
})
export class RegistrarSelectorComponent implements OnInit {
protected isMobile: boolean = false;
export class RegistrarSelectorComponent {
registrarInput = signal<string>(this.registrarService.registrarId());
filteredOptions?: string[];
allRegistrarIds = computed(() =>
this.registrarService.registrars().map((r) => r.registrarId)
);
readonly breakpoint$ = this.breakpointObserver
.observe([MOBILE_LAYOUT_BREAKPOINT])
.pipe(distinctUntilChanged());
constructor(
protected registrarService: RegistrarService,
protected breakpointObserver: BreakpointObserver
) {}
ngOnInit(): void {
this.breakpoint$.subscribe(() => this.breakpointChanged());
constructor(protected registrarService: RegistrarService) {
effect(() => {
const filterValue = this.registrarInput().toLowerCase();
this.filteredOptions = this.allRegistrarIds().filter((option) =>
option.toLowerCase().includes(filterValue)
);
});
}
private breakpointChanged() {
this.isMobile = this.breakpointObserver.isMatched(MOBILE_LAYOUT_BREAKPOINT);
onFocus() {
// We reset the list of options after selection, so that user doesn't have to clear it out
this.filteredOptions = this.allRegistrarIds();
}
onSelect(registrarId: string) {
this.registrarService.updateSelectedRegistrar(registrarId);
}
}

View File

@@ -1,4 +1,19 @@
@if(registrarService.inNewRegistrarMode()) {
<app-new-registrar />
} @else {
<div class="console-app__registrars">
<div class="console-app__registrars-header">
<h1 class="mat-headline-4">Registrars</h1>
<button
mat-flat-button
color="primary"
(click)="openNewRegistrar()"
aria-label="Add new registrar"
>
<mat-icon>add</mat-icon>
Add new registrar
</button>
</div>
<mat-form-field class="console-app__registrars-filter">
<mat-label>Search</mat-label>
<input
@@ -11,24 +26,10 @@
</mat-form-field>
<mat-table
[dataSource]="dataSource"
class="mat-elevation-z8"
class="mat-elevation-z0"
class="console-app__registrars-table"
matSort
>
<ng-container matColumnDef="edit">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row">
<button
mat-icon-button
color="primary"
aria-label="Edit registrar"
(click)="openDetails($event, row)"
>
<mat-icon>edit</mat-icon>
</button>
</mat-cell>
</ng-container>
<ng-container
*ngFor="let column of columns"
[matColumnDef]="column.columnDef"
@@ -39,16 +40,15 @@
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="registrarService.updateSelectedRegistrar(row.registrarId)"
(click)="openDetails(row.registrarId)"
></mat-row>
</mat-table>
<mat-paginator
class="mat-elevation-z8"
class="mat-elevation-z0"
[pageSizeOptions]="[5, 10, 20]"
showFirstLastButtons
></mat-paginator>
<app-dialog-bottom-sheet-wrapper
#registrarDetailsView
></app-dialog-bottom-sheet-wrapper>
</div>
}

View File

@@ -2,7 +2,6 @@
$min-width: 756px;
&__registrars {
margin-top: 1.5rem;
width: 100%;
overflow: auto;
}
@@ -16,6 +15,11 @@
min-width: $min-width !important;
}
&__registrars-header {
display: flex;
justify-content: space-between;
}
.mat-mdc-paginator {
min-width: $min-width !important;
}
@@ -27,7 +31,7 @@
}
&-driveId {
min-width: 200px;
word-break: break-all;
word-break: break-word;
}
&-registryLockAllowed {
max-width: 80px;

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,12 +14,13 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistrarComponent } from './registrarsTable.component';
import { BackendService } from '../shared/services/backend.service';
import { ActivatedRoute } from '@angular/router';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { MaterialModule } from '../material.module';
import { BackendService } from '../shared/services/backend.service';
import { RegistrarComponent } from './registrarsTable.component';
describe('RegistrarComponent', () => {
let component: RegistrarComponent;
@@ -28,14 +29,12 @@ describe('RegistrarComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RegistrarComponent],
imports: [
HttpClientTestingModule,
MaterialModule,
BrowserAnimationsModule,
],
imports: [MaterialModule, BrowserAnimationsModule],
providers: [
BackendService,
{ provide: ActivatedRoute, useValue: {} as ActivatedRoute },
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,13 +12,63 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild, ViewEncapsulation } from '@angular/core';
import { Registrar, RegistrarService } from './registrar.service';
import { Component, effect, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { RegistrarDetailsComponent } from './registrarDetails.component';
import { DialogBottomSheetWrapper } from '../shared/components/dialogBottomSheet.component';
import { Router } from '@angular/router';
import { Registrar, RegistrarService } from './registrar.service';
export const columns = [
{
columnDef: 'registrarId',
header: 'Registrar Id',
cell: (record: Registrar) => `${record.registrarId || ''}`,
hiddenOnDetailsCard: true,
},
{
columnDef: 'registrarName',
header: 'Name',
cell: (record: Registrar) => `${record.registrarName || ''}`,
hiddenOnDetailsCard: true,
},
{
columnDef: 'allowedTlds',
header: 'TLDs',
cell: (record: Registrar) => `${(record.allowedTlds || []).join(', ')}`,
},
{
columnDef: 'emailAddress',
header: 'Username',
cell: (record: Registrar) => `${record.emailAddress || ''}`,
},
{
columnDef: 'ianaIdentifier',
header: 'IANA ID',
cell: (record: Registrar) => `${record.ianaIdentifier || ''}`,
},
{
columnDef: 'billingAccountMap',
header: 'Billing Accounts',
cell: (record: Registrar) =>
`${Object.entries(record.billingAccountMap || {}).reduce(
(acc, [key, val]) => {
return `${acc}${key}=${val}<br/>`;
},
''
)}`,
},
{
columnDef: 'registryLockAllowed',
header: 'Registry Lock',
cell: (record: Registrar) => `${record.registryLockAllowed}`,
},
{
columnDef: 'driveId',
header: 'Drive ID',
cell: (record: Registrar) => `${record.driveFolderId || ''}`,
},
];
@Component({
selector: 'app-registrar',
@@ -29,66 +79,23 @@ import { DialogBottomSheetWrapper } from '../shared/components/dialogBottomSheet
export class RegistrarComponent {
public static PATH = 'registrars';
dataSource: MatTableDataSource<Registrar>;
columns = [
{
columnDef: 'registrarId',
header: 'Registrar Id',
cell: (record: Registrar) => `${record.registrarId || ''}`,
},
{
columnDef: 'registrarName',
header: 'Name',
cell: (record: Registrar) => `${record.registrarName || ''}`,
},
{
columnDef: 'allowedTlds',
header: 'TLDs',
cell: (record: Registrar) => `${(record.allowedTlds || []).join(', ')}`,
},
{
columnDef: 'emailAddress',
header: 'Username',
cell: (record: Registrar) => `${record.emailAddress || ''}`,
},
{
columnDef: 'ianaIdentifier',
header: 'IANA ID',
cell: (record: Registrar) => `${record.ianaIdentifier || ''}`,
},
{
columnDef: 'billingAccountMap',
header: 'Billing Accounts',
cell: (record: Registrar) =>
// @ts-ignore - completely legit line, but TS keeps complaining
`${Object.entries(record.billingAccountMap).reduce(
(acc, [key, val]) => {
return `${acc}${key}=${val}<br/>`;
},
''
)}`,
},
{
columnDef: 'registryLockAllowed',
header: 'Registry Lock',
cell: (record: Registrar) => `${record.registryLockAllowed}`,
},
{
columnDef: 'driveId',
header: 'Drive ID',
cell: (record: Registrar) => `${record.driveFolderId || ''}`,
},
];
displayedColumns = ['edit'].concat(this.columns.map((c) => c.columnDef));
columns = columns;
displayedColumns = this.columns.map((c) => c.columnDef);
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
@ViewChild('registrarDetailsView')
detailsComponentWrapper!: DialogBottomSheetWrapper;
constructor(protected registrarService: RegistrarService) {
constructor(
protected registrarService: RegistrarService,
private router: Router
) {
this.dataSource = new MatTableDataSource<Registrar>(
registrarService.registrars()
);
effect(() => {
this.dataSource.data = registrarService.registrars();
});
}
ngAfterViewInit() {
@@ -96,16 +103,19 @@ export class RegistrarComponent {
this.dataSource.sort = this.sort;
}
openDetails(event: MouseEvent, registrar: Registrar) {
event.stopPropagation();
this.detailsComponentWrapper.open<RegistrarDetailsComponent>(
RegistrarDetailsComponent,
{ registrar }
);
openDetails(registrarId: string) {
this.router.navigate(['registrars/', registrarId], {
queryParamsHandling: 'merge',
});
}
applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
// TODO: consider filteing out only by registrar name
this.dataSource.filter = filterValue.trim().toLowerCase();
}
openNewRegistrar() {
this.registrarService.inNewRegistrarMode.set(true);
}
}

View File

@@ -0,0 +1,16 @@
<h1 class="mat-headline-4">Resources</h1>
<div class="console-app__resources">
<div>
<div class="console-app__resources-subhead">Technical resources</div>
<a
class="text-l"
href="{{ userDataService.userData.technicalDocsUrl }}"
target="_blank"
>View onboarding FAQs, TLD information, and technical documentation on
Google Drive</a
>
</div>
<div>
<img src="./assets/resources.png" />
</div>
</div>

View File

@@ -0,0 +1,22 @@
.console-app__resources {
display: flex;
flex-wrap: wrap-reverse;
> div {
flex: 1;
display: flex;
justify-content: center;
flex-direction: column;
text-align: left;
max-width: 300px;
min-width: 200px;
margin-top: 30px;
}
img {
aspect-ratio: 1 / 1;
width: 100%;
}
&-subhead {
font-size: 20px;
margin-bottom: 20px;
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,12 +13,14 @@
// limitations under the License.
import { Component } from '@angular/core';
import { UserDataService } from 'src/app/shared/services/userData.service';
import { UserDataService } from '../shared/services/userData.service';
@Component({
selector: '[app-contact-widget]',
templateUrl: './contactWidget.component.html',
selector: 'app-resources',
templateUrl: './resources.component.html',
styleUrls: ['./resources.component.scss'],
})
export class ContactWidgetComponent {
constructor(public userDataService: UserDataService) {}
export class ResourcesComponent {
public static PATH = 'resources';
constructor(protected userDataService: UserDataService) {}
}

View File

@@ -1,48 +1,40 @@
@if (loading) {
<div class="contact__loading">
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>
@if(contactService.isContactDetailsView || contactService.isContactNewView) {
<app-contact-details></app-contact-details>
} @else {
<div>
<div class="console-app__contacts-controls">
<button
mat-flat-button
color="primary"
(click)="openNewContact()"
aria-label="Add new contact"
>
<mat-icon>add</mat-icon>
Add new contact
</button>
</div>
<div class="console-app__contacts">
@if (contactService.contacts().length === 0) {
<div class="contact__empty-contacts">
<mat-icon class="contact__empty-contacts-icon secondary-text"
<div class="console-app__empty-contacts">
<mat-icon class="console-app__empty-contacts-icon secondary-text"
>apps_outage</mat-icon
>
<h1>No contacts found</h1>
</div>
} @else { @for (group of groupedContacts(); track group.emailAddress) {
<div class="contact__cards-wrapper">
<h3>{{ group.label }}s</h3>
<mat-divider></mat-divider>
<div class="contact__cards">
<mat-card class="contact__card" *ngFor="let contact of group.contacts">
<mat-card-title>{{ contact.name }}</mat-card-title>
<p *ngIf="contact.phoneNumber">{{ contact.phoneNumber }}</p>
<p *ngIf="contact.emailAddress">{{ contact.emailAddress }}</p>
<mat-card-actions class="contact__card-actions">
<button
mat-button
color="primary"
(click)="openDetails($event, contact)"
>
<mat-icon>edit</mat-icon>Edit
</button>
<button mat-button color="accent" (click)="deleteContact(contact)">
<mat-icon>delete</mat-icon>Delete
</button>
</mat-card-actions>
</mat-card>
</div>
</div>
} }
<div class="contact__actions">
<button mat-raised-button color="primary" (click)="openCreateNew($event)">
<mat-icon>add</mat-icon>Create a Contact
</button>
</div>
<app-dialog-bottom-sheet-wrapper
#contactDetailsWrapper
></app-dialog-bottom-sheet-wrapper>
} @else {
<mat-table [dataSource]="dataSource" class="mat-elevation-z0">
<ng-container
*ngFor="let column of columns"
[matColumnDef]="column.columnDef"
>
<mat-header-cell *matHeaderCellDef> {{ column.header }} </mat-header-cell>
<mat-cell *matCellDef="let row" [innerHTML]="column.cell(row)"></mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="openDetails(row)"
></mat-row>
</mat-table>
}
</div>
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,30 +12,22 @@
// See the License for the specific language governing permissions and
// limitations under the License.
.contact {
&__cards-wrapper {
margin-top: 22px;
}
&__card {
width: 400px;
padding: 15px;
}
&__card-actions {
padding: 0;
}
&__loading {
margin: 2rem 0;
}
&__cards {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
}
&__actions {
display: flex;
justify-content: flex-start;
margin: 2rem 0;
.console-app {
&__contacts {
margin-top: -10px;
width: 100%;
overflow: auto;
mat-table {
min-width: 800px;
}
.contact__name-column-title {
color: #5f6368;
font-weight: 500;
padding: 10px 0;
}
.contact__name-column-roles {
margin-bottom: 10px;
}
}
&__empty-contacts {
display: flex;
@@ -49,22 +41,9 @@
font-size: 4rem;
margin-top: 1.5rem;
}
}
.contact-details {
&__input {
width: 100%;
}
&__group {
margin: 20px 0;
display: flex;
align-items: baseline;
}
&__group-content {
display: flex;
flex-wrap: wrap;
max-width: 450px;
* {
min-width: 200px;
}
&__contacts-controls {
position: absolute;
right: 20px;
top: 5px;
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,10 +14,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import ContactComponent from './contact.component';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { MaterialModule } from 'src/app/material.module';
import { BackendService } from 'src/app/shared/services/backend.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import ContactComponent from './contact.component';
describe('ContactComponent', () => {
let component: ContactComponent;
@@ -26,8 +27,12 @@ describe('ContactComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ContactComponent],
imports: [HttpClientTestingModule, MaterialModule],
providers: [BackendService],
imports: [MaterialModule],
providers: [
BackendService,
provideHttpClient(),
provideHttpClientTesting(),
],
}).compileComponents();
fixture = TestBed.createComponent(ContactComponent);
component = fixture.componentInstance;

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,164 +12,85 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, ViewChild, computed } from '@angular/core';
import { Contact, ContactService } from './contact.service';
import { HttpErrorResponse } from '@angular/common/http';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Component, effect, ViewEncapsulation } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { take } from 'rxjs';
import { RegistrarService } from 'src/app/registrar/registrar.service';
import {
DialogBottomSheetContent,
DialogBottomSheetWrapper,
} from 'src/app/shared/components/dialogBottomSheet.component';
enum Operations {
DELETE,
ADD,
UPDATE,
}
interface GroupedContacts {
value: string;
label: string;
contacts: Array<Contact>;
}
type ContactDetailsParams = {
close: Function;
data: {
contact: Contact;
operation: Operations;
};
};
const contactTypes: Array<GroupedContacts> = [
{ value: 'ADMIN', label: 'Primary contact', contacts: [] },
{ value: 'ABUSE', label: 'Abuse contact', contacts: [] },
{ value: 'BILLING', label: 'Billing contact', contacts: [] },
{ value: 'LEGAL', label: 'Legal contact', contacts: [] },
{ value: 'MARKETING', label: 'Marketing contact', contacts: [] },
{ value: 'TECH', label: 'Technical contact', contacts: [] },
{ value: 'WHOIS', label: 'WHOIS-Inquiry contact', contacts: [] },
];
@Component({
selector: 'app-contact-details-dialog',
templateUrl: 'contactDetails.component.html',
styleUrls: ['./contact.component.scss'],
})
export class ContactDetailsDialogComponent implements DialogBottomSheetContent {
contact?: Contact;
contactTypes = contactTypes;
contactIndex?: number;
params?: ContactDetailsParams;
constructor(
public contactService: ContactService,
private _snackBar: MatSnackBar
) {}
init(params: ContactDetailsParams) {
this.params = params;
this.contactIndex = this.contactService
.contacts()
.findIndex((c) => c === params.data.contact);
this.contact = JSON.parse(JSON.stringify(params.data.contact));
}
close() {
this.params?.close();
}
saveAndClose(e: SubmitEvent) {
e.preventDefault();
if (!this.contact || this.contactIndex === undefined) return;
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
const operation = this.params?.data.operation;
let operationObservable;
if (operation === Operations.ADD) {
operationObservable = this.contactService.addContact(this.contact);
} else if (operation === Operations.UPDATE) {
operationObservable = this.contactService.updateContact(
this.contactIndex,
this.contact
);
} else {
throw 'Unknown operation type';
}
operationObservable.subscribe({
complete: () => this.close(),
error: (err: HttpErrorResponse) => {
this._snackBar.open(err.error);
},
});
}
}
ContactService,
contactTypeToViewReadyContact,
ViewReadyContact,
} from './contact.service';
@Component({
selector: 'app-contact',
templateUrl: './contact.component.html',
styleUrls: ['./contact.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export default class ContactComponent {
public static PATH = 'contact';
public groupedContacts = computed(() => {
return this.contactService.contacts().reduce((acc, contact) => {
contact.types.forEach((contactType) => {
acc
.find((group: GroupedContacts) => group.value === contactType)
?.contacts.push(contact);
});
return acc;
}, JSON.parse(JSON.stringify(contactTypes)));
});
@ViewChild('contactDetailsWrapper')
detailsComponentWrapper!: DialogBottomSheetWrapper;
dataSource: MatTableDataSource<ViewReadyContact> =
new MatTableDataSource<ViewReadyContact>([]);
columns = [
{
columnDef: 'name',
header: 'Name',
cell: (contact: ViewReadyContact) => `
<div class="contact__name-column">
<div class="contact__name-column-title">${contact.name}</div>
<div class="contact__name-column-roles">${contact.userFriendlyTypes.join(
' • '
)}</div>
</div>
`,
},
{
columnDef: 'emailAddress',
header: 'Email',
cell: (contact: ViewReadyContact) => `${contact.emailAddress || ''}`,
},
{
columnDef: 'phoneNumber',
header: 'Phone',
cell: (contact: ViewReadyContact) => `${contact.phoneNumber || ''}`,
},
{
columnDef: 'faxNumber',
header: 'Fax',
cell: (contact: ViewReadyContact) => `${contact.faxNumber || ''}`,
},
];
displayedColumns = this.columns.map((c) => c.columnDef);
loading: boolean = false;
constructor(
public contactService: ContactService,
private _snackBar: MatSnackBar
private registrarService: RegistrarService
) {
// TODO: Refactor to registrarId service
this.loading = true;
this.contactService.fetchContacts().subscribe(() => {
this.loading = false;
effect(() => {
if (this.contactService.contacts()) {
this.dataSource = new MatTableDataSource<ViewReadyContact>(
this.contactService.contacts()
);
}
});
effect(() => {
if (this.registrarService.registrarId()) {
this.contactService.isContactDetailsView = false;
this.contactService.isContactNewView = false;
this.contactService.fetchContacts().pipe(take(1)).subscribe();
}
});
}
deleteContact(contact: Contact) {
if (confirm(`Please confirm contact ${contact.name} delete`)) {
this.contactService.deleteContact(contact).subscribe({
error: (err: HttpErrorResponse) => {
this._snackBar.open(err.error);
},
});
}
openDetails(contact: ViewReadyContact) {
this.contactService.setEditableContact(contact);
this.contactService.isContactDetailsView = true;
}
openCreateNew(e: MouseEvent) {
const newContact: Contact = {
name: '',
phoneNumber: '',
emailAddress: '',
types: [contactTypes[0].value],
};
this.openDetails(e, newContact, Operations.ADD);
}
openDetails(
e: MouseEvent,
contact: Contact,
operation: Operations = Operations.UPDATE
) {
e.preventDefault();
this.detailsComponentWrapper.open<ContactDetailsDialogComponent>(
ContactDetailsDialogComponent,
{ contact, operation }
);
openNewContact() {
this.contactService.setEditableContact();
this.contactService.isContactNewView = true;
}
}

View File

@@ -1,4 +1,4 @@
// Copyright 2023 The Nomulus Authors. All Rights Reserved.
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -13,65 +13,103 @@
// limitations under the License.
import { Injectable, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { Observable, switchMap, tap } from 'rxjs';
import { RegistrarService } from 'src/app/registrar/registrar.service';
import { BackendService } from 'src/app/shared/services/backend.service';
export type contactType =
| 'ADMIN'
| 'ABUSE'
| 'BILLING'
| 'LEGAL'
| 'MARKETING'
| 'TECH'
| 'WHOIS';
type contactTypesToUserFriendlyTypes = { [type in contactType]: string };
export const contactTypeToTextMap: contactTypesToUserFriendlyTypes = {
ADMIN: 'Primary contact',
ABUSE: 'Abuse contact',
BILLING: 'Billing contact',
LEGAL: 'Legal contact',
MARKETING: 'Marketing contact',
TECH: 'Technical contact',
WHOIS: 'WHOIS-Inquiry contact',
};
type UserFriendlyType = (typeof contactTypeToTextMap)[contactType];
export interface Contact {
name: string;
phoneNumber: string;
phoneNumber?: string;
emailAddress: string;
registrarId?: string;
faxNumber?: string;
types: Array<string>;
types: Array<contactType>;
visibleInWhoisAsAdmin?: boolean;
visibleInWhoisAsTech?: boolean;
visibleInDomainWhoisAsAbuse?: boolean;
}
export interface ViewReadyContact extends Contact {
userFriendlyTypes: Array<UserFriendlyType>;
}
export function contactTypeToViewReadyContact(c: Contact): ViewReadyContact {
return {
...c,
userFriendlyTypes: c.types?.map((cType) => contactTypeToTextMap[cType]),
};
}
@Injectable({
providedIn: 'root',
})
export class ContactService {
contacts = signal<Contact[]>([]);
contacts = signal<ViewReadyContact[]>([]);
contactInEdit!: ViewReadyContact;
isContactDetailsView: boolean = false;
isContactNewView: boolean = false;
constructor(
private backend: BackendService,
private registrarService: RegistrarService
) {}
// TODO: Come up with a better handling for registrarId
setEditableContact(contact?: ViewReadyContact) {
this.contactInEdit = contact
? contact
: contactTypeToViewReadyContact({
emailAddress: '',
name: '',
types: ['ADMIN'],
faxNumber: '',
phoneNumber: '',
registrarId: '',
});
}
fetchContacts(): Observable<Contact[]> {
return this.backend.getContacts(this.registrarService.registrarId()).pipe(
tap((contacts = []) => {
this.contacts.set(contacts);
tap((contacts) => {
this.contacts.set(contacts.map(contactTypeToViewReadyContact));
})
);
}
saveContacts(contacts: Contact[]): Observable<Contact[]> {
saveContacts(contacts: ViewReadyContact[]): Observable<Contact[]> {
return this.backend
.postContacts(this.registrarService.registrarId(), contacts)
.pipe(
tap((_) => {
this.contacts.set(contacts);
})
);
.pipe(switchMap((_) => this.fetchContacts()));
}
updateContact(index: number, contact: Contact) {
const newContacts = this.contacts().map((c, i) =>
i === index ? contact : c
);
return this.saveContacts(newContacts);
}
addContact(contact: Contact) {
addContact(contact: ViewReadyContact) {
const newContacts = this.contacts().concat([contact]);
return this.saveContacts(newContacts);
}
deleteContact(contact: Contact) {
deleteContact(contact: ViewReadyContact) {
const newContacts = this.contacts().filter((c) => c !== contact);
return this.saveContacts(newContacts);
}

View File

@@ -1,104 +1,201 @@
<h3 mat-dialog-title>Contact details</h3>
<div mat-dialog-content *ngIf="contact">
<form (ngSubmit)="saveAndClose($event)">
<p>
<mat-form-field class="contact-details__input">
<div class="console-app__contact" *ngIf="contactService.contactInEdit">
<div class="console-app__contact-controls">
<button
mat-icon-button
aria-label="Back to contacts list"
(click)="goBack()"
>
<mat-icon>arrow_back</mat-icon>
</button>
<div class="spacer"></div>
@if(!isEditing && !contactService.isContactNewView) {
<button
mat-flat-button
color="primary"
aria-label="Edit Contact"
(click)="isEditing = true"
>
<mat-icon>edit</mat-icon>
Edit
</button>
<button mat-icon-button aria-label="Delete Contact">
<mat-icon>delete</mat-icon>
</button>
}
</div>
@if(isEditing || contactService.isContactNewView) {
<h1>Contact Details</h1>
<form (ngSubmit)="save($event)">
<section>
<mat-form-field appearance="outline">
<mat-label>Name: </mat-label>
<input
matInput
[required]="true"
[(ngModel)]="contact.name"
[(ngModel)]="contactService.contactInEdit.name"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</p>
<p>
<mat-form-field class="contact-details__input">
<mat-form-field appearance="outline">
<mat-label>Primary account email: </mat-label>
<input
type="email"
matInput
[email]="true"
[required]="true"
[(ngModel)]="contact.emailAddress"
[(ngModel)]="contactService.contactInEdit.emailAddress"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</p>
<p>
<mat-form-field class="contact-details__input">
<mat-form-field appearance="outline">
<mat-label>Phone: </mat-label>
<input
matInput
[(ngModel)]="contact.phoneNumber"
[(ngModel)]="contactService.contactInEdit.phoneNumber"
[ngModelOptions]="{ standalone: true }"
placeholder="+0.0000000000"
/>
</mat-form-field>
</p>
<p>
<mat-form-field class="contact-details__input">
<mat-form-field appearance="outline">
<mat-label>Fax: </mat-label>
<input
matInput
[(ngModel)]="contact.faxNumber"
[(ngModel)]="contactService.contactInEdit.faxNumber"
[ngModelOptions]="{ standalone: true }"
/>
</mat-form-field>
</p>
</section>
<div class="contact-details__group">
<label>Contact type:</label>
<div class="contact-details__group-content">
<section>
<h1>Contact Type</h1>
<p class="console-app__contact-required">
<mat-icon color="accent">error</mat-icon>Required to select at least one
</p>
<div class="">
<mat-checkbox
*ngFor="let contactType of contactTypes"
[checked]="contact.types.includes(contactType.value)"
(change)="
$event.checked
? contact.types.push(contactType.value)
: contact.types.splice(
contact.types.indexOf(contactType.value),
1
)
"
[disabled]="
contact.types.length === 1 && contact.types[0] === contactType.value
"
*ngFor="let contactType of contactTypeToTextMap | keyvalue"
[checked]="checkboxIsChecked(contactType.key)"
(change)="checkboxOnChange($event, contactType.key)"
[disabled]="checkboxIsDisabled(contactType.key)"
>
{{ contactType.label }}
{{ contactType.value }}
</mat-checkbox>
</div>
</div>
<section>
<mat-checkbox
[(ngModel)]="contact.visibleInWhoisAsAdmin"
[ngModelOptions]="{ standalone: true }"
>Show in Registrar WHOIS record as admin contact</mat-checkbox
>
</section>
<section>
<mat-checkbox
[(ngModel)]="contact.visibleInWhoisAsTech"
[ngModelOptions]="{ standalone: true }"
>Show in Registrar WHOIS record as technical contact</mat-checkbox
>
</section>
<h1>WHOIS Preferences</h1>
<div>
<mat-checkbox
[(ngModel)]="contactService.contactInEdit.visibleInWhoisAsAdmin"
[ngModelOptions]="{ standalone: true }"
>Show in Registrar WHOIS record as admin contact</mat-checkbox
>
</div>
<section>
<mat-checkbox
[(ngModel)]="contact.visibleInDomainWhoisAsAbuse"
[ngModelOptions]="{ standalone: true }"
>Show Phone and Email in Domain WHOIS Record as registrar abuse contact
(per CL&D requirements)</mat-checkbox
>
<div>
<mat-checkbox
[(ngModel)]="contactService.contactInEdit.visibleInWhoisAsTech"
[ngModelOptions]="{ standalone: true }"
>Show in Registrar WHOIS record as technical contact</mat-checkbox
>
</div>
<div>
<mat-checkbox
[(ngModel)]="contactService.contactInEdit.visibleInDomainWhoisAsAbuse"
[ngModelOptions]="{ standalone: true }"
>Show Phone and Email in Domain WHOIS Record as registrar abuse
contact (per CL&D requirements)</mat-checkbox
>
</div>
</section>
<mat-dialog-actions>
<button mat-button (click)="close()">Cancel</button>
<button type="submit" mat-button>Save</button>
</mat-dialog-actions>
<button
class="console-app__contact-submit"
mat-flat-button
color="primary"
type="submit"
>
Save
</button>
</form>
} @else {
<h1>{{ contactService.contactInEdit.name }}</h1>
<mat-card appearance="outlined">
<mat-card-content>
<mat-list role="list">
<mat-list-item role="listitem">
<h2>Contact details</h2>
</mat-list-item>
<mat-divider></mat-divider>
<mat-list-item role="listitem">
<span class="console-app__list-key">Email</span>
<span class="console-app__list-value">{{
contactService.contactInEdit.emailAddress
}}</span>
</mat-list-item>
<mat-divider></mat-divider>
<mat-list-item role="listitem">
<span class="console-app__list-key">Phone</span>
<span class="console-app__list-value">{{
contactService.contactInEdit.phoneNumber
}}</span>
</mat-list-item>
<mat-divider></mat-divider>
<mat-list-item role="listitem">
<span class="console-app__list-key">Fax</span>
<span class="console-app__list-value">{{
contactService.contactInEdit.faxNumber
}}</span>
</mat-list-item>
<mat-divider></mat-divider>
<mat-list-item role="listitem">
<span class="console-app__list-key">Type:</span>
<span class="console-app__list-value">{{
contactService.contactInEdit.userFriendlyTypes.join(", ")
}}</span>
</mat-list-item>
</mat-list>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined">
<mat-card-content>
<mat-list role="list">
<mat-list-item role="listitem">
<h2>WHOIS Preferences</h2>
</mat-list-item>
@if(contactService.contactInEdit.visibleInWhoisAsAdmin) {
<mat-divider></mat-divider>
<mat-list-item role="listitem">
<span class="console-app__list-value"
>Show in Registrar WHOIS record as admin contact</span
>
</mat-list-item>
} @if(contactService.contactInEdit.visibleInWhoisAsTech) {
<mat-divider></mat-divider>
<mat-list-item
role="listitem"
*ngIf="contactService.contactInEdit.visibleInWhoisAsTech"
>
<span class="console-app__list-value"
>Show in Registrar WHOIS record as technical contact</span
>
</mat-list-item>
} @if(contactService.contactInEdit.visibleInDomainWhoisAsAbuse) {
<mat-divider></mat-divider>
<mat-list-item role="listitem">
<span class="console-app__list-value"
>Show Phone and Email in Domain WHOIS Record as registrar abuse
contact (per CL&D requirements)</span
>
</mat-list-item>
}
</mat-list>
</mat-card-content>
</mat-card>
}
</div>

View File

@@ -0,0 +1,31 @@
.console-app__contact {
max-width: 616px;
section {
margin-bottom: 40px;
}
&-required {
display: flex;
align-items: center;
gap: 10px;
}
&-submit {
margin: 30px 0;
}
&-controls {
display: flex;
align-items: center;
margin: 20px 0;
}
mat-card {
margin-bottom: 30px;
}
mat-form-field {
display: block;
width: 100%;
margin-bottom: 20px;
}
}

View File

@@ -0,0 +1,104 @@
// Copyright 2024 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { HttpErrorResponse } from '@angular/common/http';
import { Component } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
import { first } from 'rxjs';
import {
ContactService,
contactType,
contactTypeToTextMap,
} from './contact.service';
@Component({
selector: 'app-contact-details',
templateUrl: './contactDetails.component.html',
styleUrls: ['./contactDetails.component.scss'],
})
export class ContactDetailsComponent {
protected contactTypeToTextMap = contactTypeToTextMap;
isEditing: boolean = false;
constructor(
protected contactService: ContactService,
private _snackBar: MatSnackBar
) {}
deleteContact() {
if (
confirm(
`Please confirm deletion of contact ${this.contactService.contactInEdit.name}`
)
) {
this.contactService
.deleteContact(this.contactService.contactInEdit)
.pipe(first())
.subscribe({
error: (err: HttpErrorResponse) => {
this._snackBar.open(err.error);
},
});
}
}
goBack() {
if (this.isEditing) {
this.isEditing = false;
} else {
this.contactService.isContactNewView = false;
this.contactService.isContactDetailsView = false;
}
}
save(e: SubmitEvent) {
e.preventDefault();
const request = this.contactService.isContactNewView
? this.contactService.addContact(this.contactService.contactInEdit)
: this.contactService.saveContacts(this.contactService.contacts());
request.subscribe({
complete: () => {
this.goBack();
},
error: (err: HttpErrorResponse) => {
this._snackBar.open(err.error);
},
});
}
checkboxIsChecked(type: string) {
return this.contactService.contactInEdit.types.includes(
type as contactType
);
}
checkboxIsDisabled(type: string) {
return (
this.contactService.contactInEdit.types.length === 1 &&
this.contactService.contactInEdit.types[0] === (type as contactType)
);
}
checkboxOnChange(event: MatCheckboxChange, type: string) {
if (event.checked) {
this.contactService.contactInEdit.types.push(type as contactType);
} else {
this.contactService.contactInEdit.types =
this.contactService.contactInEdit.types.filter(
(t) => t != (type as contactType)
);
}
}
}

View File

@@ -0,0 +1,76 @@
<div class="settings-security__edit-password">
<p>
<button
mat-icon-button
aria-label="Back to security settings"
(click)="goBack()"
>
<mat-icon>arrow_back</mat-icon>
</button>
</p>
<h1>Update EPP password</h1>
<p class="secondary-text">
Passwords must be between 6 and 16 alphanumeric characters
</p>
<form
(ngSubmit)="save()"
[formGroup]="passwordUpdateForm"
class="settings-security__edit-password-form"
>
<div class="settings-security__edit-password-field">
<mat-form-field appearance="outline">
<mat-label>Old password: </mat-label>
<input
matInput
type="text"
formControlName="oldPassword"
required
autocomplete="current-password"
/>
<mat-error *ngIf="hasError('oldPassword') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<div class="settings-security__edit-password-field">
<mat-form-field appearance="outline">
<mat-label>New password: </mat-label>
<input
matInput
type="text"
formControlName="newPassword"
required
autocomplete="new-password"
/>
<mat-error *ngIf="hasError('newPassword') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<div class="settings-security__edit-password-field">
<mat-form-field appearance="outline">
<mat-label>Confirm new password: </mat-label>
<input
matInput
type="text"
formControlName="newPasswordRepeat"
required
autocomplete="new-password"
/>
<mat-error *ngIf="hasError('newPasswordRepeat') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<button
mat-flat-button
color="primary"
[disabled]="!passwordUpdateForm.valid"
aria-label="Save epp password update"
type="submit"
class="settings-security__edit-password-save"
>
Save
</button>
</form>
</div>

View File

@@ -0,0 +1,16 @@
.settings-security__edit-password {
max-width: 616px;
&-field {
width: 100%;
mat-form-field {
margin-bottom: 20px;
width: 100%;
}
}
&-form {
margin-top: 30px;
}
&-save {
margin-top: 30px;
}
}

Some files were not shown because too many files have changed in this diff Show More